From 22a9df82e6d4e3a3085982538bfb82c5a49b9d6b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 26 Jan 2023 08:46:25 -0500 Subject: [PATCH 01/29] Closes #11554: Add module types count to manufacturers list --- docs/release-notes/version-3.4.md | 1 + netbox/dcim/tables/devicetypes.py | 20 +++++++++++++++----- netbox/dcim/views.py | 1 + 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 4fedddab2..d4f238b3c 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -5,6 +5,7 @@ ### Enhancements * [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice +* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list * [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services ### Bug Fixes diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 42d9c7879..c452c3efb 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -34,10 +34,19 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): url_params={'manufacturer_id': 'pk'}, verbose_name='Device Types' ) - inventoryitem_count = tables.Column( + moduletype_count = columns.LinkedCountColumn( + viewname='dcim:moduletype_list', + url_params={'manufacturer_id': 'pk'}, + verbose_name='Module Types' + ) + inventoryitem_count = columns.LinkedCountColumn( + viewname='dcim:inventoryitem_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Inventory Items' ) - platform_count = tables.Column( + platform_count = columns.LinkedCountColumn( + viewname='dcim:platform_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Platforms' ) slug = tables.Column() @@ -48,11 +57,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Manufacturer fields = ( - 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', - 'tags', 'contacts', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', + 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', + 'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', + 'description', 'slug', ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0643ac739..80b369b6d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -842,6 +842,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView): class ManufacturerListView(generic.ObjectListView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), + moduletype_count=count_related(ModuleType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') ) From ccc108a2179606426b4e1004abc741ab20b3d9bd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 26 Jan 2023 10:53:59 -0500 Subject: [PATCH 02/29] Closes #11598: Add buttons to easily switch between rack list and elevations views --- docs/release-notes/version-3.4.md | 1 + netbox/dcim/views.py | 1 + .../templates/dcim/rack_elevation_list.html | 51 ++++++++++--------- netbox/templates/dcim/rack_list.html | 9 ++++ netbox/templates/generic/object_list.html | 7 ++- 5 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 netbox/templates/dcim/rack_list.html diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index d4f238b3c..4c9992f20 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -7,6 +7,7 @@ * [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice * [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list * [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services +* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views ### Bug Fixes diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 80b369b6d..9b49e799c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -642,6 +642,7 @@ class RackListView(generic.ObjectListView): filterset = filtersets.RackFilterSet filterset_form = forms.RackFilterForm table = tables.RackTable + template_name = 'dcim/rack_list.html' class RackElevationListView(generic.ObjectListView): diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index c9d9a248a..bd02c9f74 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -5,31 +5,34 @@ {% block title %}Rack Elevations{% endblock %} {% block controls %} -
-
-
- -
-
- Front - Rear -
- -
+
+
+ + View List + +
+ +
+
+ Front + Rear +
+
+
{% endblock %} {% block content-wrapper %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html new file mode 100644 index 000000000..897625af6 --- /dev/null +++ b/netbox/templates/dcim/rack_list.html @@ -0,0 +1,9 @@ +{% extends 'generic/object_list.html' %} +{% load helpers %} +{% load static %} + +{% block extra_controls %} + + View Elevations + +{% endblock %} \ No newline at end of file diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 8b3e317c0..e269e9da6 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -26,16 +26,15 @@ Context:
{% plugin_list_buttons model %} - {% block extra_controls %}{% endblock %} {% if 'add' in actions %} - {% add_button model %} + {% add_button model %} {% endif %} {% if 'import' in actions %} - {% import_button model %} + {% import_button model %} {% endif %} {% if 'export' in actions %} - {% export_button model %} + {% export_button model %} {% endif %}
From fbc9fea0a5b0943da6f10df3551f071ce555a6ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Jan 2023 16:44:10 -0500 Subject: [PATCH 03/29] Fixes #11267: Avoid catching ImportError exceptions when loading plugins (#11566) * Avoid catching ImportErrors when loading plugin URLs * Avoid catching ImportErrors when loading plugin resources --- netbox/extras/plugins/__init__.py | 70 ++++++++++++++++--------------- netbox/extras/plugins/urls.py | 13 +++--- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 7694a1fbe..0b2123a4e 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,4 +1,5 @@ import collections +from importlib.util import find_spec from django.apps import AppConfig from django.conf import settings @@ -21,6 +22,15 @@ registry['plugins'] = { 'template_extensions': collections.defaultdict(list), } +DEFAULT_RESOURCE_PATHS = { + 'search_indexes': 'search.indexes', + 'graphql_schema': 'graphql.schema', + 'menu': 'navigation.menu', + 'menu_items': 'navigation.menu_items', + 'template_extensions': 'template_content.template_extensions', + 'user_preferences': 'preferences.preferences', +} + # # Plugin AppConfig class @@ -58,58 +68,50 @@ class PluginConfig(AppConfig): # Django apps to append to INSTALLED_APPS when plugin requires them. django_apps = [] - # Default integration paths. Plugin authors can override these to customize the paths to - # integrated components. - search_indexes = 'search.indexes' - graphql_schema = 'graphql.schema' - menu = 'navigation.menu' - menu_items = 'navigation.menu_items' - template_extensions = 'template_content.template_extensions' - user_preferences = 'preferences.preferences' + # Optional plugin resources + search_indexes = None + graphql_schema = None + menu = None + menu_items = None + template_extensions = None + user_preferences = None + + def _load_resource(self, name): + # Import from the configured path, if defined. + if getattr(self, name): + return import_string(f"{self.__module__}.{self.name}") + # Fall back to the resource's default path. Return None if the module has not been provided. + default_path = DEFAULT_RESOURCE_PATHS[name] + default_module = f'{self.__module__}.{default_path}'.rsplit('.', 1)[0] + if find_spec(default_module): + setattr(self, name, default_path) + return import_string(f"{self.__module__}.{default_path}") def ready(self): plugin_name = self.name.rsplit('.', 1)[-1] # Register search extensions (if defined) - try: - search_indexes = import_string(f"{self.__module__}.{self.search_indexes}") - for idx in search_indexes: - register_search(idx) - except ImportError: - pass + search_indexes = self._load_resource('search_indexes') or [] + for idx in search_indexes: + register_search(idx) # Register template content (if defined) - try: - template_extensions = import_string(f"{self.__module__}.{self.template_extensions}") + if template_extensions := self._load_resource('template_extensions'): register_template_extensions(template_extensions) - except ImportError: - pass # Register navigation menu and/or menu items (if defined) - try: - menu = import_string(f"{self.__module__}.{self.menu}") + if menu := self._load_resource('menu'): register_menu(menu) - except ImportError: - pass - try: - menu_items = import_string(f"{self.__module__}.{self.menu_items}") + if menu_items := self._load_resource('menu_items'): register_menu_items(self.verbose_name, menu_items) - except ImportError: - pass # Register GraphQL schema (if defined) - try: - graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}") + if graphql_schema := self._load_resource('graphql_schema'): register_graphql_schema(graphql_schema) - except ImportError: - pass # Register user preferences (if defined) - try: - user_preferences = import_string(f"{self.__module__}.{self.user_preferences}") + if user_preferences := self._load_resource('user_preferences'): register_user_preferences(plugin_name, user_preferences) - except ImportError: - pass @classmethod def validate(cls, user_config, netbox_version): diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index b4360dc9e..2f237f56a 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -1,9 +1,11 @@ +from importlib import import_module + from django.apps import apps from django.conf import settings from django.conf.urls import include from django.contrib.admin.views.decorators import staff_member_required from django.urls import path -from django.utils.module_loading import import_string +from django.utils.module_loading import import_string, module_has_submodule from . import views @@ -19,24 +21,21 @@ plugin_admin_patterns = [ # Register base/API URL patterns for each plugin for plugin_path in settings.PLUGINS: + plugin = import_module(plugin_path) plugin_name = plugin_path.split('.')[-1] app = apps.get_app_config(plugin_name) base_url = getattr(app, 'base_url') or app.label # Check if the plugin specifies any base URLs - try: + if module_has_submodule(plugin, 'urls'): urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") plugin_patterns.append( path(f"{base_url}/", include((urlpatterns, app.label))) ) - except ImportError: - pass # Check if the plugin specifies any API URLs - try: + if module_has_submodule(plugin, 'api.urls'): urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") plugin_api_patterns.append( path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) ) - except ImportError: - pass From 0da518e83de6500bb25572fb9c2618c198915d7f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 27 Jan 2023 16:45:20 -0500 Subject: [PATCH 04/29] Changelog for #11267 --- docs/release-notes/version-3.4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 4c9992f20..612e5bb74 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -11,6 +11,7 @@ ### Bug Fixes +* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources * [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit * [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table * [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file From 892fd95b5f3813b9d3365ebe100c842e88480a41 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 27 Jan 2023 16:46:49 -0500 Subject: [PATCH 05/29] Update NetBox Cloud link --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index d61465443..6a53403d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/ * Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in * The [installation guide](./installation/index.md) will help you get your own deployment up and running * Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach -* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1 +* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/) From e7ad6eeb7482c3b5cd7f9b64738da7a52e9d5ebe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 27 Jan 2023 19:56:12 -0500 Subject: [PATCH 06/29] Fixes #11613: Correct plugin import logic fix from #11267 --- netbox/extras/plugins/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 0b2123a4e..b56113ca1 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,5 +1,5 @@ import collections -from importlib.util import find_spec +from importlib import import_module from django.apps import AppConfig from django.conf import settings @@ -80,12 +80,15 @@ class PluginConfig(AppConfig): # Import from the configured path, if defined. if getattr(self, name): return import_string(f"{self.__module__}.{self.name}") + # Fall back to the resource's default path. Return None if the module has not been provided. - default_path = DEFAULT_RESOURCE_PATHS[name] - default_module = f'{self.__module__}.{default_path}'.rsplit('.', 1)[0] - if find_spec(default_module): - setattr(self, name, default_path) - return import_string(f"{self.__module__}.{default_path}") + default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}' + default_module, resource_name = default_path.rsplit('.', 1) + try: + module = import_module(default_module) + return getattr(module, resource_name, None) + except ModuleNotFoundError: + pass def ready(self): plugin_name = self.name.rsplit('.', 1)[-1] From 46ede62f3fbb9938f5fe19f03cb1df991db69fee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 30 Jan 2023 10:25:20 -0500 Subject: [PATCH 07/29] Fix rendering of example code --- docs/plugins/development/navigation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 63402c747..5f4a8a0dc 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -51,7 +51,7 @@ menu_items = (item1, item2, item3) Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. -```python filename="navigation.py" +```python title="navigation.py" from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices From 10e27cfa00031c1875e3fd0c81776e6e3a44ad88 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 31 Jan 2023 09:56:09 -0800 Subject: [PATCH 08/29] 11620 fix interface poe type filter --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index c00e83672..4dd2f73eb 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1170,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): label='PoE mode' ) poe_type = MultipleChoiceField( - choices=InterfacePoEModeChoices, + choices=InterfacePoETypeChoices, required=False, label='PoE type' ) From a137cd6cbefa4654cc8f69c367b3258d3ccdadaa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 1 Feb 2023 10:33:45 -0500 Subject: [PATCH 09/29] Fixes #11635: Pre-populate assigned VRF when following "first available IP" link from prefix view --- docs/release-notes/version-3.4.md | 2 ++ netbox/templates/ipam/prefix.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 612e5bb74..3c92bc61d 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -17,6 +17,8 @@ * [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file * [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516)) * [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names +* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type +* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view --- diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index a0baf3325..6d986aed5 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -133,7 +133,7 @@ {% with first_available_ip=object.get_first_available_ip %} {% if first_available_ip %} {% if perms.ipam.add_ipaddress %} - {{ first_available_ip }} + {{ first_available_ip }} {% else %} {{ first_available_ip }} {% endif %} From 4d87ce56e3d04cd9d838796e50c5842e4e7771cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Feb 2023 10:06:23 -0500 Subject: [PATCH 10/29] Closes #11558: Add support for remote data sources (#11646) * WIP * WIP * Add git sync * Fix file hashing * Add last_synced to DataSource * Build out UI & API resources * Add status field to DataSource * Add UI control to sync data source * Add API endpoint to sync data sources * Fix display of DataSource job results * DataSource password should be write-only * General cleanup * Add data file UI view * Punt on HTTP, FTP support for now * Add DataSource URL validation * Add HTTP proxy support to git fetcher * Add management command to sync data sources * DataFile REST API endpoints should be read-only * Refactor fetch methods into backend classes * Replace auth & git branch fields with general-purpose parameters * Fix last_synced time * Render discrete form fields for backend parameters * Enable dynamic edit form for DataSource * Register DataBackend classes in application registry * Add search indexers for DataSource, DataFile * Add single & bulk delete views for DataFile * Add model documentation * Convert DataSource to a primary model * Introduce pre_sync & post_sync signals * Clean up migrations * Rename url to source_url * Clean up filtersets * Add API & filterset tests * Add view tests * Add initSelect() to HTMX refresh handler * Render DataSourceForm fieldsets dynamically * Update compiled static resources --- docs/models/core/datafile.md | 25 ++ docs/models/core/datasource.md | 47 +++ netbox/core/__init__.py | 0 netbox/core/api/__init__.py | 0 netbox/core/api/nested_serializers.py | 25 ++ netbox/core/api/serializers.py | 51 +++ netbox/core/api/urls.py | 13 + netbox/core/api/views.py | 52 +++ netbox/core/apps.py | 8 + netbox/core/choices.py | 34 ++ netbox/core/data_backends.py | 117 +++++++ netbox/core/exceptions.py | 2 + netbox/core/filtersets.py | 64 ++++ netbox/core/forms/__init__.py | 4 + netbox/core/forms/bulk_edit.py | 50 +++ netbox/core/forms/bulk_import.py | 15 + netbox/core/forms/filtersets.py | 49 +++ netbox/core/forms/model_forms.py | 81 +++++ netbox/core/graphql/__init__.py | 0 netbox/core/graphql/schema.py | 12 + netbox/core/graphql/types.py | 21 ++ netbox/core/jobs.py | 29 ++ netbox/core/management/__init__.py | 0 netbox/core/management/commands/__init__.py | 0 .../management/commands/syncdatasource.py | 41 +++ netbox/core/migrations/0001_initial.py | 62 ++++ netbox/core/migrations/__init__.py | 0 netbox/core/models/__init__.py | 1 + netbox/core/models/data.py | 302 ++++++++++++++++++ netbox/core/search.py | 21 ++ netbox/core/signals.py | 10 + netbox/core/tables/__init__.py | 1 + netbox/core/tables/data.py | 52 +++ netbox/core/tests/__init__.py | 0 netbox/core/tests/test_api.py | 93 ++++++ netbox/core/tests/test_filtersets.py | 120 +++++++ netbox/core/tests/test_views.py | 91 ++++++ netbox/core/urls.py | 22 ++ netbox/core/views.py | 118 +++++++ netbox/extras/management/commands/nbshell.py | 2 +- netbox/extras/models/models.py | 10 +- netbox/netbox/api/views.py | 1 + netbox/netbox/graphql/schema.py | 2 + netbox/netbox/navigation/menu.py | 1 + netbox/netbox/registry.py | 3 +- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/project-static/dist/netbox.js | Bin 380899 -> 380935 bytes netbox/project-static/dist/netbox.js.map | Bin 353676 -> 353697 bytes netbox/project-static/src/htmx.ts | 4 +- netbox/templates/core/datafile.html | 81 +++++ netbox/templates/core/datasource.html | 114 +++++++ netbox/utilities/files.py | 9 + 53 files changed, 1857 insertions(+), 6 deletions(-) create mode 100644 docs/models/core/datafile.md create mode 100644 docs/models/core/datasource.md create mode 100644 netbox/core/__init__.py create mode 100644 netbox/core/api/__init__.py create mode 100644 netbox/core/api/nested_serializers.py create mode 100644 netbox/core/api/serializers.py create mode 100644 netbox/core/api/urls.py create mode 100644 netbox/core/api/views.py create mode 100644 netbox/core/apps.py create mode 100644 netbox/core/choices.py create mode 100644 netbox/core/data_backends.py create mode 100644 netbox/core/exceptions.py create mode 100644 netbox/core/filtersets.py create mode 100644 netbox/core/forms/__init__.py create mode 100644 netbox/core/forms/bulk_edit.py create mode 100644 netbox/core/forms/bulk_import.py create mode 100644 netbox/core/forms/filtersets.py create mode 100644 netbox/core/forms/model_forms.py create mode 100644 netbox/core/graphql/__init__.py create mode 100644 netbox/core/graphql/schema.py create mode 100644 netbox/core/graphql/types.py create mode 100644 netbox/core/jobs.py create mode 100644 netbox/core/management/__init__.py create mode 100644 netbox/core/management/commands/__init__.py create mode 100644 netbox/core/management/commands/syncdatasource.py create mode 100644 netbox/core/migrations/0001_initial.py create mode 100644 netbox/core/migrations/__init__.py create mode 100644 netbox/core/models/__init__.py create mode 100644 netbox/core/models/data.py create mode 100644 netbox/core/search.py create mode 100644 netbox/core/signals.py create mode 100644 netbox/core/tables/__init__.py create mode 100644 netbox/core/tables/data.py create mode 100644 netbox/core/tests/__init__.py create mode 100644 netbox/core/tests/test_api.py create mode 100644 netbox/core/tests/test_filtersets.py create mode 100644 netbox/core/tests/test_views.py create mode 100644 netbox/core/urls.py create mode 100644 netbox/core/views.py create mode 100644 netbox/templates/core/datafile.html create mode 100644 netbox/templates/core/datasource.html create mode 100644 netbox/utilities/files.py diff --git a/docs/models/core/datafile.md b/docs/models/core/datafile.md new file mode 100644 index 000000000..3e2aa2f27 --- /dev/null +++ b/docs/models/core/datafile.md @@ -0,0 +1,25 @@ +# Data Files + +A data file object is the representation in NetBox's database of some file belonging to a remote [data source](./datasource.md). Data files are synchronized automatically, and cannot be modified locally (although they can be deleted). + +## Fields + +### Source + +The [data source](./datasource.md) to which this file belongs. + +### Path + +The path to the file, relative to its source's URL. For example, a file at `/opt/config-data/routing/bgp/peer.yaml` with a source URL of `file:///opt/config-data/` would have its path set to `routing/bgp/peer.yaml`. + +### Last Updated + +The date and time at which the file most recently updated from its source. Note that this attribute is updated only when the file's contents have been modified. Re-synchronizing the data source will not update this timestamp if the upstream file's data has not changed. + +### Size + +The file's size, in bytes. + +### Hash + +A [SHA256 hash](https://en.wikipedia.org/wiki/SHA-2) of the file's data. This can be compared to a hash taken from the original file to determine whether any changes have been made. diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md new file mode 100644 index 000000000..d16abdd10 --- /dev/null +++ b/docs/models/core/datasource.md @@ -0,0 +1,47 @@ +# Data Sources + +A data source represents some external repository of data which NetBox can consume, such as a git repository. Files within the data source are synchronized to NetBox by saving them in the database as [data file](./datafile.md) objects. + +## Fields + +### Name + +The data source's human-friendly name. + +### Type + +The type of data source. Supported options include: + +* Local directory +* git repository + +### URL + +The URL identifying the remote source. Some examples are included below. + +| Type | Example URL | +|------|-------------| +| Local | file:///var/my/data/source/ | +| git | https://https://github.com/my-organization/my-repo | + +### Status + +The source's current synchronization status. Note that this cannot be set manually: It is updated automatically when the source is synchronized. + +### Enabled + +If false, synchronization will be disabled. + +### Ignore Rules + +A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference. + +| Rule | Description | +|----------------|------------------------------------------| +| `README` | Ignore any files named `README` | +| `*.txt` | Ignore any files with a `.txt` extension | +| `data???.json` | Ignore e.g. `data123.json` | + +### Last Synced + +The date and time at which the source was most recently synchronized successfully. diff --git a/netbox/core/__init__.py b/netbox/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/api/__init__.py b/netbox/core/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/api/nested_serializers.py b/netbox/core/api/nested_serializers.py new file mode 100644 index 000000000..0a8351fec --- /dev/null +++ b/netbox/core/api/nested_serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from core.models import * +from netbox.api.serializers import WritableNestedSerializer + +__all__ = [ + 'NestedDataFileSerializer', + 'NestedDataSourceSerializer', +] + + +class NestedDataSourceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail') + + class Meta: + model = DataSource + fields = ['id', 'url', 'display', 'name'] + + +class NestedDataFileSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail') + + class Meta: + model = DataFile + fields = ['id', 'url', 'display', 'path'] diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py new file mode 100644 index 000000000..4c29fd69e --- /dev/null +++ b/netbox/core/api/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from core.choices import * +from core.models import * +from netbox.api.fields import ChoiceField +from netbox.api.serializers import NetBoxModelSerializer +from .nested_serializers import * + +__all__ = ( + 'DataSourceSerializer', +) + + +class DataSourceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datasource-detail' + ) + type = ChoiceField( + choices=DataSourceTypeChoices + ) + status = ChoiceField( + choices=DataSourceStatusChoices, + read_only=True + ) + + # Related object counts + file_count = serializers.IntegerField( + read_only=True + ) + + class Meta: + model = DataSource + fields = [ + 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', + 'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count', + ] + + +class DataFileSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datafile-detail' + ) + source = NestedDataSourceSerializer( + read_only=True + ) + + class Meta: + model = DataFile + fields = [ + 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', + ] diff --git a/netbox/core/api/urls.py b/netbox/core/api/urls.py new file mode 100644 index 000000000..364e5db55 --- /dev/null +++ b/netbox/core/api/urls.py @@ -0,0 +1,13 @@ +from netbox.api.routers import NetBoxRouter +from . import views + + +router = NetBoxRouter() +router.APIRootView = views.CoreRootView + +# Data sources +router.register('data-sources', views.DataSourceViewSet) +router.register('data-files', views.DataFileViewSet) + +app_name = 'core-api' +urlpatterns = router.urls diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py new file mode 100644 index 000000000..b2d8c0ed4 --- /dev/null +++ b/netbox/core/api/views.py @@ -0,0 +1,52 @@ +from django.shortcuts import get_object_or_404 + +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.routers import APIRootView + +from core import filtersets +from core.models import * +from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet +from utilities.utils import count_related +from . import serializers + + +class CoreRootView(APIRootView): + """ + Core API root view + """ + def get_view_name(self): + return 'Core' + + +# +# Data sources +# + +class DataSourceViewSet(NetBoxModelViewSet): + queryset = DataSource.objects.annotate( + file_count=count_related(DataFile, 'source') + ) + serializer_class = serializers.DataSourceSerializer + filterset_class = filtersets.DataSourceFilterSet + + @action(detail=True, methods=['post']) + def sync(self, request, pk): + """ + Enqueue a job to synchronize the DataSource. + """ + if not request.user.has_perm('extras.sync_datasource'): + raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") + + datasource = get_object_or_404(DataSource, pk=pk) + datasource.enqueue_sync_job(request) + serializer = serializers.DataSourceSerializer(datasource, context={'request': request}) + + return Response(serializer.data) + + +class DataFileViewSet(NetBoxReadOnlyModelViewSet): + queryset = DataFile.objects.defer('data').prefetch_related('source') + serializer_class = serializers.DataFileSerializer + filterset_class = filtersets.DataFileFilterSet diff --git a/netbox/core/apps.py b/netbox/core/apps.py new file mode 100644 index 000000000..c4886eb41 --- /dev/null +++ b/netbox/core/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "core" + + def ready(self): + from . import data_backends, search diff --git a/netbox/core/choices.py b/netbox/core/choices.py new file mode 100644 index 000000000..6927c83fb --- /dev/null +++ b/netbox/core/choices.py @@ -0,0 +1,34 @@ +from django.utils.translation import gettext as _ + +from utilities.choices import ChoiceSet + + +# +# Data sources +# + +class DataSourceTypeChoices(ChoiceSet): + LOCAL = 'local' + GIT = 'git' + + CHOICES = ( + (LOCAL, _('Local'), 'gray'), + (GIT, _('Git'), 'blue'), + ) + + +class DataSourceStatusChoices(ChoiceSet): + + NEW = 'new' + QUEUED = 'queued' + SYNCING = 'syncing' + COMPLETED = 'completed' + FAILED = 'failed' + + CHOICES = ( + (NEW, _('New'), 'blue'), + (QUEUED, _('Queued'), 'orange'), + (SYNCING, _('Syncing'), 'cyan'), + (COMPLETED, _('Completed'), 'green'), + (FAILED, _('Failed'), 'red'), + ) diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py new file mode 100644 index 000000000..5d0e80584 --- /dev/null +++ b/netbox/core/data_backends.py @@ -0,0 +1,117 @@ +import logging +import subprocess +import tempfile +from contextlib import contextmanager +from urllib.parse import quote, urlunparse, urlparse + +from django import forms +from django.conf import settings +from django.utils.translation import gettext as _ + +from netbox.registry import registry +from .choices import DataSourceTypeChoices +from .exceptions import SyncError + +__all__ = ( + 'LocalBackend', + 'GitBackend', +) + +logger = logging.getLogger('netbox.data_backends') + + +def register_backend(name): + """ + Decorator for registering a DataBackend class. + """ + def _wrapper(cls): + registry['data_backends'][name] = cls + return cls + + return _wrapper + + +class DataBackend: + parameters = {} + + def __init__(self, url, **kwargs): + self.url = url + self.params = kwargs + + @property + def url_scheme(self): + return urlparse(self.url).scheme.lower() + + @contextmanager + def fetch(self): + raise NotImplemented() + + +@register_backend(DataSourceTypeChoices.LOCAL) +class LocalBackend(DataBackend): + + @contextmanager + def fetch(self): + logger.debug(f"Data source type is local; skipping fetch") + local_path = urlparse(self.url).path # Strip file:// scheme + + yield local_path + + +@register_backend(DataSourceTypeChoices.GIT) +class GitBackend(DataBackend): + parameters = { + 'username': forms.CharField( + required=False, + label=_('Username'), + widget=forms.TextInput(attrs={'class': 'form-control'}) + ), + 'password': forms.CharField( + required=False, + label=_('Password'), + widget=forms.TextInput(attrs={'class': 'form-control'}) + ), + 'branch': forms.CharField( + required=False, + label=_('Branch'), + widget=forms.TextInput(attrs={'class': 'form-control'}) + ) + } + + @contextmanager + def fetch(self): + local_path = tempfile.TemporaryDirectory() + + # Add authentication credentials to URL (if specified) + username = self.params.get('username') + password = self.params.get('password') + if username and password: + url_components = list(urlparse(self.url)) + # Prepend username & password to netloc + url_components[1] = quote(f'{username}@{password}:') + url_components[1] + url = urlunparse(url_components) + else: + url = self.url + + # Compile git arguments + args = ['git', 'clone', '--depth', '1'] + if branch := self.params.get('branch'): + args.extend(['--branch', branch]) + args.extend([url, local_path.name]) + + # Prep environment variables + env_vars = {} + if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): + env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme) + + logger.debug(f"Cloning git repo: {' '.join(args)}") + try: + subprocess.run(args, check=True, capture_output=True, env=env_vars) + except subprocess.CalledProcessError as e: + raise SyncError( + f"Fetching remote data failed: {e.stderr}" + ) + + yield local_path.name + + local_path.cleanup() diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py new file mode 100644 index 000000000..8412b0378 --- /dev/null +++ b/netbox/core/exceptions.py @@ -0,0 +1,2 @@ +class SyncError(Exception): + pass diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py new file mode 100644 index 000000000..3bff34158 --- /dev/null +++ b/netbox/core/filtersets.py @@ -0,0 +1,64 @@ +from django.db.models import Q +from django.utils.translation import gettext as _ + +import django_filters + +from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet +from .choices import * +from .models import * + +__all__ = ( + 'DataFileFilterSet', + 'DataSourceFilterSet', +) + + +class DataSourceFilterSet(NetBoxModelFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=DataSourceTypeChoices, + null_value=None + ) + status = django_filters.MultipleChoiceFilter( + choices=DataSourceStatusChoices, + null_value=None + ) + + class Meta: + model = DataSource + fields = ('id', 'name', 'enabled') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + +class DataFileFilterSet(ChangeLoggedModelFilterSet): + q = django_filters.CharFilter( + method='search' + ) + source_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data source (ID)'), + ) + source = django_filters.ModelMultipleChoiceFilter( + field_name='source__name', + queryset=DataSource.objects.all(), + to_field_name='name', + label=_('Data source (name)'), + ) + + class Meta: + model = DataFile + fields = ('id', 'path', 'last_updated', 'size', 'hash') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(path__icontains=value) + ) diff --git a/netbox/core/forms/__init__.py b/netbox/core/forms/__init__.py new file mode 100644 index 000000000..1499f98b2 --- /dev/null +++ b/netbox/core/forms/__init__.py @@ -0,0 +1,4 @@ +from .bulk_edit import * +from .bulk_import import * +from .filtersets import * +from .model_forms import * diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py new file mode 100644 index 000000000..c5713b626 --- /dev/null +++ b/netbox/core/forms/bulk_edit.py @@ -0,0 +1,50 @@ +from django import forms +from django.utils.translation import gettext as _ + +from core.choices import DataSourceTypeChoices +from core.models import * +from netbox.forms import NetBoxModelBulkEditForm +from utilities.forms import ( + add_blank_choice, BulkEditNullBooleanSelect, CommentField, SmallTextarea, StaticSelect, +) + +__all__ = ( + 'DataSourceBulkEditForm', +) + + +class DataSourceBulkEditForm(NetBoxModelBulkEditForm): + type = forms.ChoiceField( + choices=add_blank_choice(DataSourceTypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label=_('Enforce unique space') + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label=_('Comments') + ) + parameters = forms.JSONField( + required=False + ) + ignore_rules = forms.CharField( + required=False, + widget=forms.Textarea() + ) + + model = DataSource + fieldsets = ( + (None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')), + ) + nullable_fields = ( + 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', + ) diff --git a/netbox/core/forms/bulk_import.py b/netbox/core/forms/bulk_import.py new file mode 100644 index 000000000..78a859dcb --- /dev/null +++ b/netbox/core/forms/bulk_import.py @@ -0,0 +1,15 @@ +from core.models import * +from netbox.forms import NetBoxModelImportForm + +__all__ = ( + 'DataSourceImportForm', +) + + +class DataSourceImportForm(NetBoxModelImportForm): + + class Meta: + model = DataSource + fields = ( + 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules', + ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py new file mode 100644 index 000000000..433f07067 --- /dev/null +++ b/netbox/core/forms/filtersets.py @@ -0,0 +1,49 @@ +from django import forms +from django.utils.translation import gettext as _ + +from core.choices import * +from core.models import * +from netbox.forms import NetBoxModelFilterSetForm +from utilities.forms import ( + BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, +) + +__all__ = ( + 'DataFileFilterForm', + 'DataSourceFilterForm', +) + + +class DataSourceFilterForm(NetBoxModelFilterSetForm): + model = DataSource + fieldsets = ( + (None, ('q', 'filter_id')), + ('Data Source', ('type', 'status')), + ) + type = MultipleChoiceField( + choices=DataSourceTypeChoices, + required=False + ) + status = MultipleChoiceField( + choices=DataSourceStatusChoices, + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class DataFileFilterForm(NetBoxModelFilterSetForm): + model = DataFile + fieldsets = ( + (None, ('q', 'filter_id')), + ('File', ('source_id',)), + ) + source_id = DynamicModelMultipleChoiceField( + queryset=DataSource.objects.all(), + required=False, + label=_('Data source') + ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py new file mode 100644 index 000000000..786e71c3a --- /dev/null +++ b/netbox/core/forms/model_forms.py @@ -0,0 +1,81 @@ +import copy + +from django import forms + +from core.models import * +from netbox.forms import NetBoxModelForm, StaticSelect +from netbox.registry import registry +from utilities.forms import CommentField + +__all__ = ( + 'DataSourceForm', +) + + +class DataSourceForm(NetBoxModelForm): + comments = CommentField() + + class Meta: + model = DataSource + fields = [ + 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', + ] + widgets = { + 'type': StaticSelect( + attrs={ + 'hx-get': '.', + 'hx-include': '#form_fields input', + 'hx-target': '#form_fields', + } + ), + 'ignore_rules': forms.Textarea( + attrs={ + 'rows': 5, + 'class': 'font-monospace', + 'placeholder': '.cache\n*.txt' + } + ), + } + + @property + def fieldsets(self): + fieldsets = [ + ('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')), + ] + if self.backend_fields: + fieldsets.append( + ('Backend', self.backend_fields) + ) + + return fieldsets + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + backend_classes = registry['data_backends'] + + if self.is_bound and self.data.get('type') in backend_classes: + type_ = self.data['type'] + elif self.initial and self.initial.get('type') in backend_classes: + type_ = self.initial['type'] + else: + type_ = self.fields['type'].initial + backend = backend_classes.get(type_) + + self.backend_fields = [] + for name, form_field in backend.parameters.items(): + field_name = f'backend_{name}' + self.backend_fields.append(field_name) + self.fields[field_name] = copy.copy(form_field) + if self.instance and self.instance.parameters: + self.fields[field_name].initial = self.instance.parameters.get(name) + + def save(self, *args, **kwargs): + + parameters = {} + for name in self.fields: + if name.startswith('backend_'): + parameters[name[8:]] = self.cleaned_data[name] + self.instance.parameters = parameters + + return super().save(*args, **kwargs) diff --git a/netbox/core/graphql/__init__.py b/netbox/core/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py new file mode 100644 index 000000000..201965430 --- /dev/null +++ b/netbox/core/graphql/schema.py @@ -0,0 +1,12 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class CoreQuery(graphene.ObjectType): + data_file = ObjectField(DataFileType) + data_file_list = ObjectListField(DataFileType) + + data_source = ObjectField(DataSourceType) + data_source_list = ObjectListField(DataSourceType) diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py new file mode 100644 index 000000000..402e36345 --- /dev/null +++ b/netbox/core/graphql/types.py @@ -0,0 +1,21 @@ +from core import filtersets, models +from netbox.graphql.types import BaseObjectType, NetBoxObjectType + +__all__ = ( + 'DataFileType', + 'DataSourceType', +) + + +class DataFileType(BaseObjectType): + class Meta: + model = models.DataFile + exclude = ('data',) + filterset_class = filtersets.DataFileFilterSet + + +class DataSourceType(NetBoxObjectType): + class Meta: + model = models.DataSource + fields = '__all__' + filterset_class = filtersets.DataSourceFilterSet diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py new file mode 100644 index 000000000..ee285fa7c --- /dev/null +++ b/netbox/core/jobs.py @@ -0,0 +1,29 @@ +import logging + +from extras.choices import JobResultStatusChoices +from netbox.search.backends import search_backend +from .choices import * +from .exceptions import SyncError +from .models import DataSource + +logger = logging.getLogger(__name__) + + +def sync_datasource(job_result, *args, **kwargs): + """ + Call sync() on a DataSource. + """ + datasource = DataSource.objects.get(name=job_result.name) + + try: + job_result.start() + datasource.sync() + + # Update the search cache for DataFiles belonging to this source + search_backend.cache(datasource.datafiles.iterator()) + + except SyncError as e: + job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) + job_result.save() + DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) + logging.error(e) diff --git a/netbox/core/management/__init__.py b/netbox/core/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/management/commands/__init__.py b/netbox/core/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/management/commands/syncdatasource.py b/netbox/core/management/commands/syncdatasource.py new file mode 100644 index 000000000..3d73f70ab --- /dev/null +++ b/netbox/core/management/commands/syncdatasource.py @@ -0,0 +1,41 @@ +from django.core.management.base import BaseCommand, CommandError + +from core.models import DataSource + + +class Command(BaseCommand): + help = "Synchronize a data source from its remote upstream" + + def add_arguments(self, parser): + parser.add_argument('name', nargs='*', help="Data source(s) to synchronize") + parser.add_argument( + "--all", action='store_true', dest='sync_all', + help="Synchronize all data sources" + ) + + def handle(self, *args, **options): + + # Find DataSources to sync + if options['sync_all']: + datasources = DataSource.objects.all() + elif options['name']: + datasources = DataSource.objects.filter(name__in=options['name']) + # Check for invalid names + found_names = {ds['name'] for ds in datasources.values('name')} + if invalid_names := set(options['name']) - found_names: + raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}") + else: + raise CommandError(f"Must specify at least one data source, or set --all.") + + if len(options['name']) > 1: + self.stdout.write(f"Syncing {len(datasources)} data sources.") + + for i, datasource in enumerate(datasources, start=1): + self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='') + self.stdout.flush() + datasource.sync() + self.stdout.write(datasource.get_status_display()) + self.stdout.flush() + + if len(options['name']) > 1: + self.stdout.write(f"Finished.") diff --git a/netbox/core/migrations/0001_initial.py b/netbox/core/migrations/0001_initial.py new file mode 100644 index 000000000..803ac3b13 --- /dev/null +++ b/netbox/core/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 4.1.5 on 2023-02-02 02:37 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('extras', '0084_staging'), + ] + + operations = [ + migrations.CreateModel( + name='DataSource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('type', models.CharField(default='local', max_length=50)), + ('source_url', models.CharField(max_length=200)), + ('status', models.CharField(default='new', editable=False, max_length=50)), + ('enabled', models.BooleanField(default=True)), + ('ignore_rules', models.TextField(blank=True)), + ('parameters', models.JSONField(blank=True, null=True)), + ('last_synced', models.DateTimeField(blank=True, editable=False, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='DataFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('path', models.CharField(editable=False, max_length=1000)), + ('last_updated', models.DateTimeField(editable=False)), + ('size', models.PositiveIntegerField(editable=False)), + ('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])), + ('data', models.BinaryField()), + ('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='datafiles', to='core.datasource')), + ], + options={ + 'ordering': ('source', 'path'), + }, + ), + migrations.AddConstraint( + model_name='datafile', + constraint=models.UniqueConstraint(fields=('source', 'path'), name='core_datafile_unique_source_path'), + ), + ] diff --git a/netbox/core/migrations/__init__.py b/netbox/core/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py new file mode 100644 index 000000000..df22d8bbb --- /dev/null +++ b/netbox/core/models/__init__.py @@ -0,0 +1 @@ +from .data import * diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py new file mode 100644 index 000000000..5ad048b0f --- /dev/null +++ b/netbox/core/models/data.py @@ -0,0 +1,302 @@ +import logging +import os +from fnmatch import fnmatchcase +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.module_loading import import_string +from django.utils.translation import gettext as _ + +from extras.models import JobResult +from netbox.models import PrimaryModel +from netbox.models.features import ChangeLoggingMixin +from netbox.registry import registry +from utilities.files import sha256_hash +from utilities.querysets import RestrictedQuerySet +from ..choices import * +from ..exceptions import SyncError +from ..signals import post_sync, pre_sync + +__all__ = ( + 'DataFile', + 'DataSource', +) + +logger = logging.getLogger('netbox.core.data') + + +class DataSource(PrimaryModel): + """ + A remote source, such as a git repository, from which DataFiles are synchronized. + """ + name = models.CharField( + max_length=100, + unique=True + ) + type = models.CharField( + max_length=50, + choices=DataSourceTypeChoices, + default=DataSourceTypeChoices.LOCAL + ) + source_url = models.CharField( + max_length=200, + verbose_name=_('URL') + ) + status = models.CharField( + max_length=50, + choices=DataSourceStatusChoices, + default=DataSourceStatusChoices.NEW, + editable=False + ) + enabled = models.BooleanField( + default=True + ) + ignore_rules = models.TextField( + blank=True, + help_text=_("Patterns (one per line) matching files to ignore when syncing") + ) + parameters = models.JSONField( + blank=True, + null=True + ) + last_synced = models.DateTimeField( + blank=True, + null=True, + editable=False + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return f'{self.name}' + + def get_absolute_url(self): + return reverse('core:datasource', args=[self.pk]) + + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' + + def get_type_color(self): + return DataSourceTypeChoices.colors.get(self.type) + + def get_status_color(self): + return DataSourceStatusChoices.colors.get(self.status) + + @property + def url_scheme(self): + return urlparse(self.source_url).scheme.lower() + + @property + def ready_for_sync(self): + return self.enabled and self.status not in ( + DataSourceStatusChoices.QUEUED, + DataSourceStatusChoices.SYNCING + ) + + def clean(self): + + # Ensure URL scheme matches selected type + if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''): + raise ValidationError({ + 'url': f"URLs for local sources must start with file:// (or omit the scheme)" + }) + + def enqueue_sync_job(self, request): + """ + Enqueue a background job to synchronize the DataSource by calling sync(). + """ + # Set the status to "syncing" + self.status = DataSourceStatusChoices.QUEUED + + # Enqueue a sync job + job_result = JobResult.enqueue_job( + import_string('core.jobs.sync_datasource'), + name=self.name, + obj_type=ContentType.objects.get_for_model(DataSource), + user=request.user, + ) + + return job_result + + def get_backend(self): + backend_cls = registry['data_backends'].get(self.type) + backend_params = self.parameters or {} + + return backend_cls(self.source_url, **backend_params) + + def sync(self): + """ + Create/update/delete child DataFiles as necessary to synchronize with the remote source. + """ + if not self.ready_for_sync: + raise SyncError(f"Cannot initiate sync; data source not ready/enabled") + + # Emit the pre_sync signal + pre_sync.send(sender=self.__class__, instance=self) + + self.status = DataSourceStatusChoices.SYNCING + DataSource.objects.filter(pk=self.pk).update(status=self.status) + + # Replicate source data locally + backend = self.get_backend() + with backend.fetch() as local_path: + + logger.debug(f'Syncing files from source root {local_path}') + data_files = self.datafiles.all() + known_paths = {df.path for df in data_files} + logger.debug(f'Starting with {len(known_paths)} known files') + + # Check for any updated/deleted files + updated_files = [] + deleted_file_ids = [] + for datafile in data_files: + + try: + if datafile.refresh_from_disk(source_root=local_path): + updated_files.append(datafile) + except FileNotFoundError: + # File no longer exists + deleted_file_ids.append(datafile.pk) + continue + + # Bulk update modified files + updated_count = DataFile.objects.bulk_update(updated_files, ['hash']) + logger.debug(f"Updated {updated_count} files") + + # Bulk delete deleted files + deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete() + logger.debug(f"Deleted {updated_count} files") + + # Walk the local replication to find new files + new_paths = self._walk(local_path) - known_paths + + # Bulk create new files + new_datafiles = [] + for path in new_paths: + datafile = DataFile(source=self, path=path) + datafile.refresh_from_disk(source_root=local_path) + datafile.full_clean() + new_datafiles.append(datafile) + created_count = len(DataFile.objects.bulk_create(new_datafiles, batch_size=100)) + logger.debug(f"Created {created_count} data files") + + # Update status & last_synced time + self.status = DataSourceStatusChoices.COMPLETED + self.last_synced = timezone.now() + DataSource.objects.filter(pk=self.pk).update(status=self.status, last_synced=self.last_synced) + + # Emit the post_sync signal + post_sync.send(sender=self.__class__, instance=self) + + def _walk(self, root): + """ + Return a set of all non-excluded files within the root path. + """ + logger.debug(f"Walking {root}...") + paths = set() + + for path, dir_names, file_names in os.walk(root): + path = path.split(root)[1].lstrip('/') # Strip root path + if path.startswith('.'): + continue + for file_name in file_names: + if not self._ignore(file_name): + paths.add(os.path.join(path, file_name)) + + logger.debug(f"Found {len(paths)} files") + return paths + + def _ignore(self, filename): + """ + Returns a boolean indicating whether the file should be ignored per the DataSource's configured + ignore rules. + """ + if filename.startswith('.'): + return True + for rule in self.ignore_rules.splitlines(): + if fnmatchcase(filename, rule): + return True + return False + + +class DataFile(ChangeLoggingMixin, models.Model): + """ + The database representation of a remote file fetched from a remote DataSource. DataFile instances should be created, + updated, or deleted only by calling DataSource.sync(). + """ + source = models.ForeignKey( + to='core.DataSource', + on_delete=models.CASCADE, + related_name='datafiles', + editable=False + ) + path = models.CharField( + max_length=1000, + editable=False, + help_text=_("File path relative to the data source's root") + ) + last_updated = models.DateTimeField( + editable=False + ) + size = models.PositiveIntegerField( + editable=False + ) + hash = models.CharField( + max_length=64, + editable=False, + validators=[ + RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters.")) + ], + help_text=_("SHA256 hash of the file data") + ) + data = models.BinaryField() + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('source', 'path') + constraints = ( + models.UniqueConstraint( + fields=('source', 'path'), + name='%(app_label)s_%(class)s_unique_source_path' + ), + ) + + def __str__(self): + return self.path + + def get_absolute_url(self): + return reverse('core:datafile', args=[self.pk]) + + @property + def data_as_string(self): + try: + return self.data.tobytes().decode('utf-8') + except UnicodeDecodeError: + return None + + def refresh_from_disk(self, source_root): + """ + Update instance attributes from the file on disk. Returns True if any attribute + has changed. + """ + file_path = os.path.join(source_root, self.path) + file_hash = sha256_hash(file_path).hexdigest() + + # Update instance file attributes & data + if is_modified := file_hash != self.hash: + self.last_updated = timezone.now() + self.size = os.path.getsize(file_path) + self.hash = file_hash + with open(file_path, 'rb') as f: + self.data = f.read() + + return is_modified diff --git a/netbox/core/search.py b/netbox/core/search.py new file mode 100644 index 000000000..e6d3005e6 --- /dev/null +++ b/netbox/core/search.py @@ -0,0 +1,21 @@ +from netbox.search import SearchIndex, register_search +from . import models + + +@register_search +class DataSourceIndex(SearchIndex): + model = models.DataSource + fields = ( + ('name', 100), + ('source_url', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class DataFileIndex(SearchIndex): + model = models.DataFile + fields = ( + ('path', 200), + ) diff --git a/netbox/core/signals.py b/netbox/core/signals.py new file mode 100644 index 000000000..65ca293f5 --- /dev/null +++ b/netbox/core/signals.py @@ -0,0 +1,10 @@ +import django.dispatch + +__all__ = ( + 'post_sync', + 'pre_sync', +) + +# DataSource signals +pre_sync = django.dispatch.Signal() +post_sync = django.dispatch.Signal() diff --git a/netbox/core/tables/__init__.py b/netbox/core/tables/__init__.py new file mode 100644 index 000000000..df22d8bbb --- /dev/null +++ b/netbox/core/tables/__init__.py @@ -0,0 +1 @@ +from .data import * diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py new file mode 100644 index 000000000..8409e3b82 --- /dev/null +++ b/netbox/core/tables/data.py @@ -0,0 +1,52 @@ +import django_tables2 as tables + +from core.models import * +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'DataFileTable', + 'DataSourceTable', +) + + +class DataSourceTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + type = columns.ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() + enabled = columns.BooleanColumn() + tags = columns.TagColumn( + url_name='core:datasource_list' + ) + file_count = tables.Column( + verbose_name='Files' + ) + + class Meta(NetBoxTable.Meta): + model = DataSource + fields = ( + 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created', + 'last_updated', 'file_count', + ) + default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') + + +class DataFileTable(NetBoxTable): + source = tables.Column( + linkify=True + ) + path = tables.Column( + linkify=True + ) + last_updated = columns.DateTimeColumn() + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = DataFile + fields = ( + 'pk', 'id', 'source', 'path', 'last_updated', 'size', 'hash', + ) + default_columns = ('pk', 'source', 'path', 'size', 'last_updated') diff --git a/netbox/core/tests/__init__.py b/netbox/core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py new file mode 100644 index 000000000..dc6d6a5ce --- /dev/null +++ b/netbox/core/tests/test_api.py @@ -0,0 +1,93 @@ +from django.urls import reverse +from django.utils import timezone + +from utilities.testing import APITestCase, APIViewTestCases +from ..choices import * +from ..models import * + + +class AppTest(APITestCase): + + def test_root(self): + url = reverse('core-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class DataSourceTest(APIViewTestCases.APIViewTestCase): + model = DataSource + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'enabled': False, + 'description': 'foo bar baz', + } + + @classmethod + def setUpTestData(cls): + data_sources = ( + DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + ) + DataSource.objects.bulk_create(data_sources) + + cls.create_data = [ + { + 'name': 'Data Source 4', + 'type': DataSourceTypeChoices.GIT, + 'source_url': 'https://example.com/git/source4' + }, + { + 'name': 'Data Source 5', + 'type': DataSourceTypeChoices.GIT, + 'source_url': 'https://example.com/git/source5' + }, + { + 'name': 'Data Source 6', + 'type': DataSourceTypeChoices.GIT, + 'source_url': 'https://example.com/git/source6' + }, + ] + + +class DataFileTest( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.GraphQLTestCase +): + model = DataFile + brief_fields = ['display', 'id', 'path', 'url'] + + @classmethod + def setUpTestData(cls): + datasource = DataSource.objects.create( + name='Data Source 1', + type=DataSourceTypeChoices.LOCAL, + source_url='file:///var/tmp/source1/' + ) + + data_files = ( + DataFile( + source=datasource, + path='dir1/file1.txt', + last_updated=timezone.now(), + size=1000, + hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1' + ), + DataFile( + source=datasource, + path='dir1/file2.txt', + last_updated=timezone.now(), + size=2000, + hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2' + ), + DataFile( + source=datasource, + path='dir1/file3.txt', + last_updated=timezone.now(), + size=3000, + hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a' + ), + ) + DataFile.objects.bulk_create(data_files) diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py new file mode 100644 index 000000000..e1e916f70 --- /dev/null +++ b/netbox/core/tests/test_filtersets.py @@ -0,0 +1,120 @@ +from datetime import datetime + +from django.test import TestCase +from django.utils import timezone + +from utilities.testing import ChangeLoggedFilterSetTests +from ..choices import * +from ..filtersets import * +from ..models import * + + +class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = DataSource.objects.all() + filterset = DataSourceFilterSet + + @classmethod + def setUpTestData(cls): + data_sources = ( + DataSource( + name='Data Source 1', + type=DataSourceTypeChoices.LOCAL, + source_url='file:///var/tmp/source1/', + status=DataSourceStatusChoices.NEW, + enabled=True + ), + DataSource( + name='Data Source 2', + type=DataSourceTypeChoices.LOCAL, + source_url='file:///var/tmp/source2/', + status=DataSourceStatusChoices.SYNCING, + enabled=True + ), + DataSource( + name='Data Source 3', + type=DataSourceTypeChoices.GIT, + source_url='https://example.com/git/source3', + status=DataSourceStatusChoices.COMPLETED, + enabled=False + ), + ) + DataSource.objects.bulk_create(data_sources) + + def test_name(self): + params = {'name': ['Data Source 1', 'Data Source 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [DataSourceTypeChoices.LOCAL]} + 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_status(self): + params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = DataFile.objects.all() + filterset = DataFileFilterSet + + @classmethod + def setUpTestData(cls): + data_sources = ( + DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + ) + DataSource.objects.bulk_create(data_sources) + + data_files = ( + DataFile( + source=data_sources[0], + path='dir1/file1.txt', + last_updated=datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + size=1000, + hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1' + ), + DataFile( + source=data_sources[1], + path='dir1/file2.txt', + last_updated=datetime(2023, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + size=2000, + hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2' + ), + DataFile( + source=data_sources[2], + path='dir1/file3.txt', + last_updated=datetime(2023, 1, 3, 0, 0, 0, tzinfo=timezone.utc), + size=3000, + hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a' + ), + ) + DataFile.objects.bulk_create(data_files) + + def test_source(self): + sources = DataSource.objects.all() + params = {'source_id': [sources[0].pk, sources[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'source': [sources[0].name, sources[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_path(self): + params = {'path': ['dir1/file1.txt', 'dir1/file2.txt']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_size(self): + params = {'size': [1000, 2000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_hash(self): + params = {'hash': [ + '442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1', + 'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2', + ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py new file mode 100644 index 000000000..fbee031ed --- /dev/null +++ b/netbox/core/tests/test_views.py @@ -0,0 +1,91 @@ +from django.utils import timezone + +from utilities.testing import ViewTestCases, create_tags +from ..choices import * +from ..models import * + + +class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = DataSource + + @classmethod + def setUpTestData(cls): + data_sources = ( + DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + ) + DataSource.objects.bulk_create(data_sources) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Data Source X', + 'type': DataSourceTypeChoices.GIT, + 'source_url': 'http:///exmaple/com/foo/bar/', + 'description': 'Something', + 'comments': 'Foo bar baz', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + f"name,type,source_url,enabled", + f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", + f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", + f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false", + ) + + cls.csv_update_data = ( + "id,name,description", + f"{data_sources[0].pk},Data Source 7,New description7", + f"{data_sources[1].pk},Data Source 8,New description8", + f"{data_sources[2].pk},Data Source 9,New description9", + ) + + cls.bulk_edit_data = { + 'enabled': False, + 'description': 'New description', + } + + +class DataFileTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = DataFile + + @classmethod + def setUpTestData(cls): + datasource = DataSource.objects.create( + name='Data Source 1', + type=DataSourceTypeChoices.LOCAL, + source_url='file:///var/tmp/source1/' + ) + + data_files = ( + DataFile( + source=datasource, + path='dir1/file1.txt', + last_updated=timezone.now(), + size=1000, + hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1' + ), + DataFile( + source=datasource, + path='dir1/file2.txt', + last_updated=timezone.now(), + size=2000, + hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2' + ), + DataFile( + source=datasource, + path='dir1/file3.txt', + last_updated=timezone.now(), + size=3000, + hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a' + ), + ) + DataFile.objects.bulk_create(data_files) diff --git a/netbox/core/urls.py b/netbox/core/urls.py new file mode 100644 index 000000000..128020890 --- /dev/null +++ b/netbox/core/urls.py @@ -0,0 +1,22 @@ +from django.urls import include, path + +from utilities.urls import get_model_urls +from . import views + +app_name = 'core' +urlpatterns = ( + + # Data sources + path('data-sources/', views.DataSourceListView.as_view(), name='datasource_list'), + path('data-sources/add/', views.DataSourceEditView.as_view(), name='datasource_add'), + path('data-sources/import/', views.DataSourceBulkImportView.as_view(), name='datasource_import'), + path('data-sources/edit/', views.DataSourceBulkEditView.as_view(), name='datasource_bulk_edit'), + path('data-sources/delete/', views.DataSourceBulkDeleteView.as_view(), name='datasource_bulk_delete'), + path('data-sources//', include(get_model_urls('core', 'datasource'))), + + # Data files + path('data-files/', views.DataFileListView.as_view(), name='datafile_list'), + path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'), + path('data-files//', include(get_model_urls('core', 'datafile'))), + +) diff --git a/netbox/core/views.py b/netbox/core/views.py new file mode 100644 index 000000000..63905228e --- /dev/null +++ b/netbox/core/views.py @@ -0,0 +1,118 @@ +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect + +from netbox.views import generic +from netbox.views.generic.base import BaseObjectView +from utilities.utils import count_related +from utilities.views import register_model_view +from . import filtersets, forms, tables +from .models import * + + +# +# Data sources +# + +class DataSourceListView(generic.ObjectListView): + queryset = DataSource.objects.annotate( + file_count=count_related(DataFile, 'source') + ) + filterset = filtersets.DataSourceFilterSet + filterset_form = forms.DataSourceFilterForm + table = tables.DataSourceTable + + +@register_model_view(DataSource) +class DataSourceView(generic.ObjectView): + queryset = DataSource.objects.all() + + def get_extra_context(self, request, instance): + related_models = ( + (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'), + ) + + return { + 'related_models': related_models, + } + + +@register_model_view(DataSource, 'sync') +class DataSourceSyncView(BaseObjectView): + queryset = DataSource.objects.all() + + def get_required_permission(self): + return 'core.sync_datasource' + + def get(self, request, pk): + # Redirect GET requests to the object view + datasource = get_object_or_404(self.queryset, pk=pk) + return redirect(datasource.get_absolute_url()) + + def post(self, request, pk): + datasource = get_object_or_404(self.queryset, pk=pk) + job_result = datasource.enqueue_sync_job(request) + + messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}") + return redirect(datasource.get_absolute_url()) + + +@register_model_view(DataSource, 'edit') +class DataSourceEditView(generic.ObjectEditView): + queryset = DataSource.objects.all() + form = forms.DataSourceForm + + +@register_model_view(DataSource, 'delete') +class DataSourceDeleteView(generic.ObjectDeleteView): + queryset = DataSource.objects.all() + + +class DataSourceBulkImportView(generic.BulkImportView): + queryset = DataSource.objects.all() + model_form = forms.DataSourceImportForm + table = tables.DataSourceTable + + +class DataSourceBulkEditView(generic.BulkEditView): + queryset = DataSource.objects.annotate( + count_files=count_related(DataFile, 'source') + ) + filterset = filtersets.DataSourceFilterSet + table = tables.DataSourceTable + form = forms.DataSourceBulkEditForm + + +class DataSourceBulkDeleteView(generic.BulkDeleteView): + queryset = DataSource.objects.annotate( + count_files=count_related(DataFile, 'source') + ) + filterset = filtersets.DataSourceFilterSet + table = tables.DataSourceTable + + +# +# Data files +# + +class DataFileListView(generic.ObjectListView): + queryset = DataFile.objects.defer('data') + filterset = filtersets.DataFileFilterSet + filterset_form = forms.DataFileFilterForm + table = tables.DataFileTable + actions = ('bulk_delete',) + + +@register_model_view(DataFile) +class DataFileView(generic.ObjectView): + queryset = DataFile.objects.all() + + +@register_model_view(DataFile, 'delete') +class DataFileDeleteView(generic.ObjectDeleteView): + queryset = DataFile.objects.all() + + +class DataFileBulkDeleteView(generic.BulkDeleteView): + queryset = DataFile.objects.defer('data') + filterset = filtersets.DataFileFilterSet + table = tables.DataFileTable diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 07f943d15..04a67eb49 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -9,7 +9,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand -APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless') +APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless') BANNER_TEXT = """### NetBox interactive shell ({node}) ### Python {python} | Django {django} | NetBox {netbox} diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e608f81b1..df32d6ac4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -11,6 +11,7 @@ from django.core.validators import MinValueValidator, ValidationError from django.db import models from django.http import HttpResponse, QueryDict from django.urls import reverse +from django.urls.exceptions import NoReverseMatch from django.utils import timezone from django.utils.formats import date_format from django.utils.translation import gettext as _ @@ -634,7 +635,7 @@ class JobResult(models.Model): def delete(self, *args, **kwargs): super().delete(*args, **kwargs) - rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT) + rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.model, RQ_QUEUE_DEFAULT) queue = django_rq.get_queue(rq_queue_name) job = queue.fetch_job(str(self.job_id)) @@ -642,7 +643,10 @@ class JobResult(models.Model): job.cancel() def get_absolute_url(self): - return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk]) + try: + return reverse(f'extras:{self.obj_type.model}_result', args=[self.pk]) + except NoReverseMatch: + return None def get_status_color(self): return JobResultStatusChoices.colors.get(self.status) @@ -693,7 +697,7 @@ class JobResult(models.Model): schedule_at: Schedule the job to be executed at the passed date and time interval: Recurrence interval (in minutes) """ - rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT) + rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.model, RQ_QUEUE_DEFAULT) queue = django_rq.get_queue(rq_queue_name) status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING job_result: JobResult = JobResult.objects.create( diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 6c6083959..023843bca 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -27,6 +27,7 @@ class APIRootView(APIView): return Response({ 'circuits': reverse('circuits-api:api-root', request=request, format=format), + 'core': reverse('core-api:api-root', request=request, format=format), 'dcim': reverse('dcim-api:api-root', request=request, format=format), 'extras': reverse('extras-api:api-root', request=request, format=format), 'ipam': reverse('ipam-api:api-root', request=request, format=format), diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 82abfb4d5..7224f3c38 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,6 +1,7 @@ import graphene from circuits.graphql.schema import CircuitsQuery +from core.graphql.schema import CoreQuery from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery @@ -14,6 +15,7 @@ from wireless.graphql.schema import WirelessQuery class Query( UsersQuery, CircuitsQuery, + CoreQuery, DCIMQuery, ExtrasQuery, IPAMQuery, diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 83a81690f..6fce7dfe6 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -287,6 +287,7 @@ OTHER_MENU = Menu( MenuGroup( label=_('Integrations'), items=( + get_model_item('core', 'datasource', _('Data Sources')), get_model_item('extras', 'webhook', _('Webhooks')), MenuItem( link='extras:report_list', diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 76886e791..670bca683 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -25,9 +25,10 @@ class Registry(dict): # Initialize the global registry registry = Registry() +registry['data_backends'] = dict() +registry['denormalized_fields'] = collections.defaultdict(list) registry['model_features'] = { feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } -registry['denormalized_fields'] = collections.defaultdict(list) registry['search'] = dict() registry['views'] = collections.defaultdict(dict) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8b2ec9730..c22adf6c2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -331,6 +331,7 @@ INSTALLED_APPS = [ 'social_django', 'taggit', 'timezone_field', + 'core', 'circuits', 'dcim', 'ipam', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 84e899ed2..22c47f7bb 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -42,6 +42,7 @@ _patterns = [ # Apps path('circuits/', include('circuits.urls')), + path('core/', include('core.urls')), path('dcim/', include('dcim.urls')), path('extras/', include('extras.urls')), path('ipam/', include('ipam.urls')), @@ -53,6 +54,7 @@ _patterns = [ # API path('api/', APIRootView.as_view(), name='api-root'), path('api/circuits/', include('circuits.api.urls')), + path('api/core/', include('core.api.urls')), path('api/dcim/', include('dcim.api.urls')), path('api/extras/', include('extras.api.urls')), path('api/ipam/', include('ipam.api.urls')), diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 19cdae0bd318381cbc0701ff151e7f87ad3ae9ee..e5793c1282d63d16d4b6e1eb8cf2ea7e3be55c7a 100644 GIT binary patch delta 16968 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 delta 16934 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 diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index d0563b9fc670f99db1fe7ed2c7fb22555367e360..1ef66b9dbd29ebfc3129c12d135db9c8bbc1916c 100644 GIT binary patch delta 89 zcmeApAUn{}F;K_T(a}?<(8pAlu*3FIncXZbA Vbac*{9+=G9#OSvDN;2zNMgZCk6w?3z diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index 70ed4f534..bd80a0c49 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -1,8 +1,10 @@ import { getElements, isTruthy } from './util'; import { initButtons } from './buttons'; +import { initSelect } from './select'; function initDepedencies(): void { - for (const init of [initButtons]) { + console.log('initDepedencies()'); + for (const init of [initButtons, initSelect]) { init(); } } diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html new file mode 100644 index 000000000..a10f5039d --- /dev/null +++ b/netbox/templates/core/datafile.html @@ -0,0 +1,81 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block controls %} +
+
+ {% plugin_buttons object %} +
+ {% if request.user|can_delete:object %} + {% delete_button object %} + {% endif %} +
+ {% custom_links object %} +
+
+{% endblock controls %} + +{% block content %} +
+
+
+
Data File
+
+ + + + + + + + + + + + + + + + + + + + + +
Source{{ object.source }}
Path + {{ object.path }} + + + +
Last Updated{{ object.last_updated }}
Size{{ object.size }} byte{{ object.size|pluralize }}
SHA256 Hash + {{ object.hash }} + + + +
+
+
+
+
Content
+
+
{{ object.data_as_string }}
+
+
+ {% plugin_left_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html new file mode 100644 index 000000000..168ced700 --- /dev/null +++ b/netbox/templates/core/datasource.html @@ -0,0 +1,114 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block extra_controls %} + {% if perms.core.sync_datasource %} + {% if object.ready_for_sync %} +
+ {% csrf_token %} + +
+ {% else %} + + {% endif %} + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+
Data Source
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Type{{ object.get_type_display }}
Enabled{% checkmark object.enabled %}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Last synced{{ object.last_synced|placeholder }}
Description{{ object.description|placeholder }}
URL + {{ object.url }} +
Ignore rules + {% if object.ignore_rules %} +
{{ object.ignore_rules }}
+ {% else %} + {{ ''|placeholder }} + {% endif %}
+
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
Backend
+
+ + {% for name, field in object.get_backend.parameters.items %} + + + + + {% empty %} + + + + {% endfor %} +
{{ field.label }}{{ object.parameters|get_key:name|placeholder }}
+ No parameters defined +
+
+
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Files
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/files.py b/netbox/utilities/files.py new file mode 100644 index 000000000..68afe2962 --- /dev/null +++ b/netbox/utilities/files.py @@ -0,0 +1,9 @@ +import hashlib + + +def sha256_hash(filepath): + """ + Return the SHA256 hash of the file at the specified path. + """ + with open(filepath, 'rb') as f: + return hashlib.sha256(f.read()) From fb2771370cdf7f1d17db8e0f3985785333372d79 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 2 Feb 2023 06:33:57 -0800 Subject: [PATCH 11/29] handled scripts error when only interval is used --- netbox/extras/forms/scripts.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 79dc8c869..8216c5413 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -45,12 +45,16 @@ class ScriptForm(BootstrapMixin, forms.Form): self.fields['_interval'] = interval self.fields['_commit'] = commit - def clean__schedule_at(self): + def clean(self): scheduled_time = self.cleaned_data['_schedule_at'] - if scheduled_time and scheduled_time < timezone.now(): + if scheduled_time and scheduled_time < local_now(): raise forms.ValidationError(_('Scheduled time must be in the future.')) - return scheduled_time + # When interval is used without schedule at, raise an exception + if self.cleaned_data['_interval'] and not scheduled_time: + raise forms.ValidationError(_('Scheduled time must be set when recurs is used.')) + + return self.cleaned_data @property def requires_input(self): From 169869bf675d4bb7b15a8e2cb90ac068be6a54c7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 2 Feb 2023 12:46:49 -0500 Subject: [PATCH 12/29] #11558: Fix URL display under data source view --- netbox/templates/core/datasource.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index 168ced700..061017ad7 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -55,7 +55,7 @@ URL - {{ object.url }} + {{ object.source_url }} From 98a2f3e4979fa473f5d508dae5f50913177a88c9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 2 Feb 2023 14:18:32 -0500 Subject: [PATCH 13/29] Refresh the README --- README.md | 119 ++++++++++++++++++------------------------------------ 1 file changed, 39 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index e14f31b56..053aa8461 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,59 @@
NetBox logo + + The premiere source of truth powering network automation
+![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) + NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. -Available as open source software under the Apache 2.0 license, NetBox is -employed by thousands of organizations around the world. +Available as open source software under the Apache 2.0 license, NetBox serves +as the cornerstone for network automation in thousands of organizations. -![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) - -[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits) -[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues) -[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls) -[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors) -
Stats via [Repography](https://repography.com) - -## About NetBox +* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power! +* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support. +* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure. +* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets. +* **Organization:** Manage tenant and contact assignments natively. +* **Powerful search:** Easily find anything you need using a single global search function. +* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time. +* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more! +* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions. +* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs. +* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own! ![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI") -Myriad infrastructure components can be modeled in NetBox, including: +## Getting Started -* Hierarchical regions, site groups, sites, and locations -* Racks, devices, and device components -* Cables and wireless connections -* Power distribution -* Data circuits and providers -* Virtual machines and clusters -* IP prefixes, ranges, and addresses -* VRFs and route targets -* L2VPN and overlays -* FHRP groups (VRRP, HSRP, etc.) -* AS numbers -* VLANs and scoped VLAN groups -* Organizational tenants and contacts +* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now! +* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction. +* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/). +* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox! -In addition to its extensive built-in models and functionality, NetBox can be -customized and extended through the use of: +## Get Involved -* Custom fields -* Custom links -* Configuration contexts -* Custom model validation rules -* Reports -* Custom scripts -* Export templates -* Conditional webhooks -* Plugins -* Single sign-on (SSO) authentication -* NAPALM integration -* Detailed change logging +* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter! +* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)! +* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub. +* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started. -NetBox also features a complete REST API as well as a GraphQL API for easily -integrating with other tools and systems. - -The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). -A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev). - -NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) -Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a -complete list of requirements, see `requirements.txt`. The code is available -[on GitHub](https://github.com/netbox-community/netbox). +## Project Stats + +
+ Timeline graph + Issues graph + Pull requests graph + Top contributors +
Stats via Repography +
+ +## Sponsors
-

Thank you to our sponsors!

[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)            @@ -76,34 +64,10 @@ complete list of requirements, see `requirements.txt`. The code is available [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) -            - [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech)
-### Discussion - -* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions -* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out - -### Installation - -Please see [the documentation](https://docs.netbox.dev/) for -instructions on installing NetBox. To upgrade NetBox, please download the -[latest release](https://github.com/netbox-community/netbox/releases) and -run `upgrade.sh`. - -### Providing Feedback - -The best platform for general feedback, assistance, and other discussion is our -[GitHub discussions](https://github.com/netbox-community/netbox/discussions). -To report a bug or request a specific feature, please open a GitHub issue using -the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose). - -If you are interested in contributing to the development of NetBox, please read -our [contributing guide](CONTRIBUTING.md) prior to beginning any work. - -### Screenshots +## Screenshots ![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)") @@ -112,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work. ![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy") ![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing") - -### Related projects - -Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) -for a list of relevant community projects. From 95b2acb6031b94bb88bca846f5352ce307def44f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 2 Feb 2023 14:59:16 -0500 Subject: [PATCH 14/29] Fixes #11650: Display error message when attempting to create device component with duplicate name --- docs/release-notes/version-3.4.md | 2 ++ netbox/netbox/views/generic/object_views.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 3c92bc61d..130b14544 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -17,8 +17,10 @@ * [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file * [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516)) * [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names +* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts * [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type * [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view +* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name --- diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 795f4ad56..d855490d1 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -453,6 +453,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): if component_form.is_valid(): new_components.append(component_form) + else: + form.errors.update(component_form.errors) + break if not form.errors and not component_form.errors: try: From 699edd049c5b7426e01e1471b7f36d560ed182ce Mon Sep 17 00:00:00 2001 From: Maximilian Wilhelm Date: Thu, 2 Feb 2023 21:22:55 +0100 Subject: [PATCH 15/29] Closes #11152: Add support to abort custom script gracefully (#11621) Signed-off-by: Maximilian Wilhelm --- docs/customization/custom-scripts.md | 13 +++++++++++++ netbox/extras/scripts.py | 10 +++++++++- netbox/utilities/exceptions.py | 7 +++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 456bcf472..af1e9b5b6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -142,6 +142,19 @@ obj.full_clean() obj.save() ``` +## Error handling + +Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported. + +Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message. + +```python +from utilities.exceptions import AbortScript + +if some_error: + raise AbortScript("Some meaningful error message") +``` + ## Variable Reference ### Default Options diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 998d727a4..77c96de56 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -21,7 +21,7 @@ from extras.models import JobResult from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator -from utilities.exceptions import AbortTransaction +from utilities.exceptions import AbortScript, AbortTransaction from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField from .context_managers import change_logging from .forms import ScriptForm @@ -470,6 +470,14 @@ def run_script(data, request, commit=True, *args, **kwargs): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") clear_webhooks.send(request) + except AbortScript as e: + script.log_failure( + f"Script aborted with error: {e}" + ) + script.log_info("Database changes have been reverted due to error.") + logger.error(f"Script aborted with error: {e}") + job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 657e90745..d7418d0cb 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -24,6 +24,13 @@ class AbortRequest(Exception): self.message = message +class AbortScript(Exception): + """ + Raised to cleanly abort a script. + """ + pass + + class PermissionsViolation(Exception): """ Raised when an operation was prevented because it would violate the From 37d0135cab92339652239775da4c94c67fe914e1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 2 Feb 2023 15:24:54 -0500 Subject: [PATCH 16/29] Release v3.4.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 2 +- docs/release-notes/version-3.4.md | 3 ++- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 80810f2ba..9ed740fff 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.4.3 + placeholder: v3.4.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 975fc025a..8e4ab54a5 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.4.3 + placeholder: v3.4.4 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 3e4811ece..7292c676b 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # HTML sanitizer # https://github.com/mozilla/bleach -bleach +bleach<6.0 # The Python web framework on which NetBox is built # https://github.com/django/django diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 130b14544..1581ce681 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,10 +1,11 @@ # NetBox v3.4 -## v3.4.4 (FUTURE) +## v3.4.4 (2023-02-02) ### Enhancements * [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice +* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts * [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list * [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services * [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4d74307a0..8517efca1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.4.4-dev' +VERSION = '3.4.4' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 3ab7faace..3cb2529a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==5.0.1 -Django==4.1.5 +Django==4.1.6 django-cors-headers==3.13.0 django-debug-toolbar==3.8.1 django-filter==22.1 @@ -19,13 +19,13 @@ graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.0.6 +mkdocs-material==9.0.10 mkdocstrings[python-legacy]==0.20.0 netaddr==0.8.0 Pillow==9.4.0 psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.13.0 +sentry-sdk==1.14.0 social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 From 7ebfa4c1d1890fafb6393d97e88aa76229368f67 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 2 Feb 2023 15:41:24 -0500 Subject: [PATCH 17/29] PRVB --- docs/release-notes/version-3.4.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 1581ce681..15b84436d 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,9 @@ # NetBox v3.4 +## v3.4.5 (FUTURE) + +--- + ## v3.4.4 (2023-02-02) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8517efca1..cda6ee643 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.4.4' +VERSION = '3.4.5-dev' # Hostname HOSTNAME = platform.node() From 1f11cd095c19f383e3cbb5e389320324095c018d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 3 Feb 2023 14:57:24 -0500 Subject: [PATCH 18/29] Fixes #11659: Include all relevant DataFile attributes during bulk update --- netbox/core/models/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 5ad048b0f..54e1dca04 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -168,7 +168,7 @@ class DataSource(PrimaryModel): continue # Bulk update modified files - updated_count = DataFile.objects.bulk_update(updated_files, ['hash']) + updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data')) logger.debug(f"Updated {updated_count} files") # Bulk delete deleted files From 2c35c53f778d5d5a561ad63d60006b106b0b7ea3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Feb 2023 16:44:05 -0500 Subject: [PATCH 19/29] Closes #9073: Remote data support for config contexts (#11692) * WIP * Add bulk sync view for config contexts * Introduce 'sync' permission for synced data models * Docs & cleanup * Remove unused method * Add a REST API endpoint to synchronize config context data --- docs/models/extras/configcontext.md | 4 + docs/release-notes/version-3.5.md | 1 + netbox/core/models/data.py | 8 + netbox/extras/api/serializers.py | 10 +- netbox/extras/api/views.py | 6 +- netbox/extras/constants.py | 1 + netbox/extras/filtersets.py | 11 +- netbox/extras/forms/filtersets.py | 15 ++ netbox/extras/forms/mixins.py | 20 +- netbox/extras/forms/model_forms.py | 18 +- .../0085_configcontext_synced_data.py | 35 ++++ netbox/extras/models/configcontexts.py | 12 +- netbox/extras/tables/tables.py | 17 +- netbox/extras/urls.py | 1 + netbox/extras/views.py | 7 +- netbox/netbox/api/features.py | 30 +++ netbox/netbox/models/features.py | 84 ++++++++- netbox/netbox/views/generic/feature_views.py | 56 +++++- netbox/templates/extras/configcontext.html | 174 ++++++++++-------- .../templates/extras/configcontext_list.html | 10 + 20 files changed, 426 insertions(+), 94 deletions(-) create mode 100644 netbox/extras/migrations/0085_configcontext_synced_data.py create mode 100644 netbox/netbox/api/features.py create mode 100644 netbox/templates/extras/configcontext_list.html diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index 156b2d784..1e58b9e01 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont The context data expressed in JSON format. +### Data File + +Config context data may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local data for the config context: It will be populated automatically from the data file. + ### Is Active If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context. diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index ae2d319b3..985953d47 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,6 +4,7 @@ ### Enhancements +* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources * [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging * [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces * [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 54e1dca04..4228c599c 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -1,5 +1,6 @@ import logging import os +import yaml from fnmatch import fnmatchcase from urllib.parse import urlparse @@ -283,6 +284,13 @@ class DataFile(ChangeLoggingMixin, models.Model): except UnicodeDecodeError: return None + def get_data(self): + """ + Attempt to read the file data as JSON/YAML and return a native Python object. + """ + # TODO: Something more robust + return yaml.safe_load(self.data_as_string) + def refresh_from_disk(self, source_root): """ Update instance attributes from the file on disk. Returns True if any attribute diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8b9c6dcb1..54627fbb3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers +from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer from dcim.api.nested_serializers import ( NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, @@ -358,13 +359,20 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + data_source = NestedDataSourceSerializer( + required=False + ) + data_file = NestedDataFileSerializer( + read_only=True + ) class Meta: model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', - 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', + 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', + 'created', 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1423824cd..8b97491b1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -17,6 +17,7 @@ from extras.models import CustomField from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException @@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet): # Config contexts # -class ConfigContextViewSet(NetBoxModelViewSet): +class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source', + 'data_file', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 123eb0a45..7c7fe331e 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -8,6 +8,7 @@ EXTRAS_FEATURES = [ 'export_templates', 'job_results', 'journaling', + 'synced_data', 'tags', 'webhooks' ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 74b98ccf6..799e79123 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ +from core.models import DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup @@ -422,10 +423,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label=_('Tag (slug)'), ) + data_source_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data source (ID)'), + ) + data_file_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data file (ID)'), + ) class Meta: model = ConfigContext - fields = ['id', 'name', 'is_active'] + fields = ['id', 'name', 'is_active', 'data_synced'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index e6a9089bc..9256d104d 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ +from core.models import DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * @@ -263,11 +264,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag_id')), + ('Data', ('data_source_id', 'data_file_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Tenant', ('tenant_group_id', 'tenant_id')) ) + data_source_id = DynamicModelMultipleChoiceField( + queryset=DataSource.objects.all(), + required=False, + label=_('Data source') + ) + data_file_id = DynamicModelMultipleChoiceField( + queryset=DataFile.objects.all(), + required=False, + label=_('Data file'), + query_params={ + 'source_id': '$data_source_id' + } + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index 640bcc3dc..4e05e3a1e 100644 --- a/netbox/extras/forms/mixins.py +++ b/netbox/extras/forms/mixins.py @@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType from django import forms from django.utils.translation import gettext as _ +from core.models import DataFile, DataSource from extras.models import * from extras.choices import CustomFieldVisibilityChoices -from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', 'SavedFiltersMixin', + 'SyncedDataMixin', ) @@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form): 'usable': True, } ) + + +class SyncedDataMixin(forms.Form): + data_source = DynamicModelChoiceField( + queryset=DataSource.objects.all(), + required=False, + label=_('Data source') + ) + data_file = DynamicModelChoiceField( + queryset=DataFile.objects.all(), + required=False, + label=_('File'), + query_params={ + 'source_id': '$data_source', + } + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a21cf21e2..429c4140a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * +from extras.forms.mixins import SyncedDataMixin from extras.models import * from extras.utils import FeatureQuery from netbox.forms import NetBoxModelForm @@ -183,7 +184,7 @@ class TagForm(BootstrapMixin, forms.ModelForm): ] -class ConfigContextForm(BootstrapMixin, forms.ModelForm): +class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): regions = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False @@ -236,10 +237,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Tag.objects.all(), required=False ) - data = JSONField() + data = JSONField( + required=False + ) fieldsets = ( ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), + ('Data Source', ('data_source', 'data_file')), ('Assignment', ( 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', @@ -251,9 +255,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): fields = ( 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', + 'tenants', 'tags', 'data_source', 'data_file', ) + def clean(self): + super().clean() + + if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_source'): + raise forms.ValidationError("Must specify either local data or a data source") + + return self.cleaned_data + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): diff --git a/netbox/extras/migrations/0085_configcontext_synced_data.py b/netbox/extras/migrations/0085_configcontext_synced_data.py new file mode 100644 index 000000000..f3022665b --- /dev/null +++ b/netbox/extras/migrations/0085_configcontext_synced_data.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.6 on 2023-02-06 15:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('extras', '0084_staging'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='data_file', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'), + ), + migrations.AddField( + model_name='configcontext', + name='data_path', + field=models.CharField(blank=True, editable=False, max_length=1000), + ), + migrations.AddField( + model_name='configcontext', + name='data_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), + ), + migrations.AddField( + model_name='configcontext', + name='data_synced', + field=models.DateTimeField(blank=True, editable=False, null=True), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index d8d3510d7..7b6088324 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -2,10 +2,11 @@ from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse +from django.utils import timezone from extras.querysets import ConfigContextQuerySet from netbox.models import ChangeLoggedModel -from netbox.models.features import WebhooksMixin +from netbox.models.features import SyncedDataMixin, WebhooksMixin from utilities.utils import deepmerge @@ -19,7 +20,7 @@ __all__ = ( # Config contexts # -class ConfigContext(WebhooksMixin, ChangeLoggedModel): +class ConfigContext(SyncedDataMixin, 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 @@ -130,6 +131,13 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): {'data': 'JSON data must be in object form. Example: {"foo": 123}'} ) + def sync_data(self): + """ + Synchronize context data from the designated DataFile (if any). + """ + self.data = self.data_file.get_data() + self.data_synced = timezone.now() + class ConfigContextModel(models.Model): """ diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index c2b8c9424..51443ad87 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -188,21 +188,30 @@ class TaggedItemTable(NetBoxTable): class ConfigContextTable(NetBoxTable): + data_source = tables.Column( + linkify=True + ) + data_file = tables.Column( + linkify=True + ) name = tables.Column( linkify=True ) is_active = columns.BooleanColumn( verbose_name='Active' ) + is_synced = columns.BooleanColumn( + verbose_name='Synced' + ) class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', - 'last_updated', + 'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations', + 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', + 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'weight', 'is_active', 'description') + default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description') class ObjectChangeTable(NetBoxTable): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f41a45f5a..6fd178284 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -60,6 +60,7 @@ urlpatterns = [ path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'), path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), # Image attachments diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2d2608ae8..c46890c19 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -352,7 +352,8 @@ class ConfigContextListView(generic.ObjectListView): filterset = filtersets.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable - actions = ('add', 'bulk_edit', 'bulk_delete') + template_name = 'extras/configcontext_list.html' + actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync') @register_model_view(ConfigContext) @@ -416,6 +417,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView): table = tables.ConfigContextTable +class ConfigContextBulkSyncDataView(generic.BulkSyncDataView): + queryset = ConfigContext.objects.all() + + class ObjectConfigContextView(generic.ObjectView): base_template = None template_name = 'extras/object_configcontext.html' diff --git a/netbox/netbox/api/features.py b/netbox/netbox/api/features.py new file mode 100644 index 000000000..db018ca12 --- /dev/null +++ b/netbox/netbox/api/features.py @@ -0,0 +1,30 @@ +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response + +from utilities.permissions import get_permission_for_model + +__all__ = ( + 'SyncedDataMixin', +) + + +class SyncedDataMixin: + + @action(detail=True, methods=['post']) + def sync(self, request, pk): + """ + Provide a /sync API endpoint to synchronize an object's data from its associated DataFile (if any). + """ + permission = get_permission_for_model(self.queryset.model, 'sync') + if not request.user.has_perm(permission): + raise PermissionDenied(f"Missing permission: {permission}") + + obj = get_object_or_404(self.queryset, pk=pk) + if obj.data_file: + obj.sync_data() + obj.save() + serializer = self.serializer_class(obj, context={'request': request}) + + return Response(serializer.data) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8e5af0ab5..f31b6e071 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -2,11 +2,12 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation -from django.db.models.signals import class_prepared -from django.dispatch import receiver - from django.core.validators import ValidationError from django.db import models +from django.db.models.signals import class_prepared +from django.dispatch import receiver +from django.utils.translation import gettext as _ + from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices @@ -25,6 +26,7 @@ __all__ = ( 'ExportTemplatesMixin', 'JobResultsMixin', 'JournalingMixin', + 'SyncedDataMixin', 'TagsMixin', 'WebhooksMixin', ) @@ -313,12 +315,82 @@ class WebhooksMixin(models.Model): abstract = True +class SyncedDataMixin(models.Model): + """ + Enables population of local data from a DataFile object, synchronized from a remote DatSource. + """ + data_source = models.ForeignKey( + to='core.DataSource', + on_delete=models.PROTECT, + blank=True, + null=True, + related_name='+', + help_text=_("Remote data source") + ) + data_file = models.ForeignKey( + to='core.DataFile', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='+' + ) + data_path = models.CharField( + max_length=1000, + blank=True, + editable=False, + help_text=_("Path to remote file (relative to data source root)") + ) + data_synced = models.DateTimeField( + blank=True, + null=True, + editable=False + ) + + class Meta: + abstract = True + + @property + def is_synced(self): + return self.data_file and self.data_synced >= self.data_file.last_updated + + def clean(self): + if self.data_file: + self.sync_data() + self.data_path = self.data_file.path + + if self.data_source and not self.data_file: + raise ValidationError({ + 'data_file': _(f"Must specify a data file when designating a data source.") + }) + if self.data_file and not self.data_source: + self.data_source = self.data_file.source + + super().clean() + + def resolve_data_file(self): + """ + Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if + either attribute is unset, or if no matching DataFile is found. + """ + from core.models import DataFile + + if self.data_source and self.data_path: + try: + return DataFile.objects.get(source=self.data_source, path=self.data_path) + except DataFile.DoesNotExist: + pass + + def sync_data(self): + raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.") + + FEATURES_MAP = ( ('custom_fields', CustomFieldsMixin), ('custom_links', CustomLinksMixin), ('export_templates', ExportTemplatesMixin), ('job_results', JobResultsMixin), ('journaling', JournalingMixin), + ('synced_data', SyncedDataMixin), ('tags', TagsMixin), ('webhooks', WebhooksMixin), ) @@ -344,3 +416,9 @@ def _register_features(sender, **kwargs): 'changelog', kwargs={'model': sender} )('netbox.views.generic.ObjectChangeLogView') + if issubclass(sender, SyncedDataMixin): + register_model_view( + sender, + 'sync', + kwargs={'model': sender} + )('netbox.views.generic.ObjectSyncDataView') diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index d4d02ee4e..6e310c97a 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -1,16 +1,22 @@ from django.contrib.contenttypes.models import ContentType +from django.contrib import messages +from django.db import transaction from django.db.models import Q -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ from django.views.generic import View from extras import forms, tables from extras.models import * -from utilities.views import ViewTab +from utilities.permissions import get_permission_for_model +from utilities.views import GetReturnURLMixin, ViewTab +from .base import BaseMultiObjectView __all__ = ( + 'BulkSyncDataView', 'ObjectChangeLogView', 'ObjectJournalView', + 'ObjectSyncDataView', ) @@ -126,3 +132,49 @@ class ObjectJournalView(View): 'base_template': self.base_template, 'tab': self.tab, }) + + +class ObjectSyncDataView(View): + + def post(self, request, model, **kwargs): + """ + Synchronize data from the DataFile associated with this object. + """ + qs = model.objects.all() + if hasattr(model.objects, 'restrict'): + qs = qs.restrict(request.user, 'sync') + obj = get_object_or_404(qs, **kwargs) + + if not obj.data_file: + messages.error(request, f"Unable to synchronize data: No data file set.") + return redirect(obj.get_absolute_url()) + + obj.sync_data() + obj.save() + messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.") + + return redirect(obj.get_absolute_url()) + + +class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView): + """ + Synchronize multiple instances of a model inheriting from SyncedDataMixin. + """ + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'sync') + + def post(self, request): + selected_objects = self.queryset.filter( + pk__in=request.POST.getlist('pk'), + data_file__isnull=False + ) + + with transaction.atomic(): + for obj in selected_objects: + obj.sync_data() + obj.save() + + model_name = self.queryset.model._meta.verbose_name_plural + messages.success(request, f"Synced {len(selected_objects)} {model_name}") + + return redirect(self.get_return_url(request)) diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 56ec52c07..3714b3f1c 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -3,81 +3,107 @@ {% load static %} {% block content %} -
-
-
-
- Config Context -
-
- - - - - - - - - - - - - - - - - -
Name - {{ object.name }} -
Weight - {{ object.weight }} -
Description{{ object.description|placeholder }}
Active - {% if object.is_active %} - - - - {% else %} - - - - {% endif %} -
-
-
-
-
- Assignment -
-
- - {% for title, objects in assigned_objects %} - - - - - {% endfor %} -
{{ title }} -
    - {% for object in objects %} -
  • {{ object|linkify }}
  • - {% empty %} -
  • None
  • - {% endfor %} -
-
-
-
+
+
+
+
Config Context
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Weight{{ object.weight }}
Description{{ object.description|placeholder }}
Active{% checkmark object.is_active %}
Data Source + {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data File + {% if object.data_file %} + {{ object.data_file }} + {% elif object.data_path %} +
+ +
+ {{ object.data_path }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data Synced{{ object.data_synced|placeholder }}
-
-
-
-
Data
- {% include 'extras/inc/configcontext_format.html' %} -
-
- {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} -
-
+
+
+
Assignment
+
+ + {% for title, objects in assigned_objects %} + + + + + {% endfor %} +
{{ title }} +
    + {% for object in objects %} +
  • {{ object|linkify }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+
+
+
+
+
Data
+ {% include 'extras/inc/configcontext_format.html' %} +
+
+ {% if object.data_file and object.data_file.last_updated > object.data_synced %} + + {% endif %} + {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} +
+
+
+
{% endblock %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html new file mode 100644 index 000000000..31e7087ad --- /dev/null +++ b/netbox/templates/extras/configcontext_list.html @@ -0,0 +1,10 @@ +{% extends 'generic/object_list.html' %} + +{% block bulk_buttons %} + {% if perms.extras.sync_configcontext %} + + {% endif %} + {{ block.super }} +{% endblock %} From 9862888a34fdedb9d96d62f1886dc3472030407c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 8 Feb 2023 18:24:18 -0500 Subject: [PATCH 20/29] Closes #11693: Enable remote data synchronization for export templates --- docs/models/extras/exporttemplate.md | 4 + netbox/extras/api/serializers.py | 9 +- netbox/extras/api/views.py | 4 +- netbox/extras/filtersets.py | 10 +- netbox/extras/forms/filtersets.py | 16 ++- netbox/extras/forms/model_forms.py | 21 ++- .../0086_exporttemplate_synced_data.py | 35 +++++ netbox/extras/models/models.py | 12 +- netbox/extras/tables/tables.py | 13 +- netbox/extras/urls.py | 1 + netbox/extras/views.py | 6 + netbox/templates/extras/configcontext.html | 26 +--- netbox/templates/extras/exporttemplate.html | 134 +++++++++++------- .../templates/extras/exporttemplate_list.html | 10 ++ netbox/templates/inc/sync_warning.html | 13 ++ netbox/utilities/templates/buttons/sync.html | 6 + netbox/utilities/templatetags/buttons.py | 10 ++ netbox/utilities/templatetags/perms.py | 5 + 18 files changed, 246 insertions(+), 89 deletions(-) create mode 100644 netbox/extras/migrations/0086_exporttemplate_synced_data.py create mode 100644 netbox/templates/extras/exporttemplate_list.html create mode 100644 netbox/templates/inc/sync_warning.html create mode 100644 netbox/utilities/templates/buttons/sync.html diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index 3215201b3..d2f9292c6 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -12,6 +12,10 @@ The name of the export template. This will appear in the "export" dropdown list The type of NetBox object to which the export template applies. +### Data File + +Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local content for the template: It will be populated automatically from the data file. + ### Template Code Jinja2 template code for rendering the exported data. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 54627fbb3..6a8248548 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -142,12 +142,19 @@ class ExportTemplateSerializer(ValidatedModelSerializer): queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), many=True ) + data_source = NestedDataSourceSerializer( + required=False + ) + data_file = NestedDataFileSerializer( + read_only=True + ) class Meta: model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'created', 'last_updated', + 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', + 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 8b97491b1..190b32f53 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -92,9 +92,9 @@ class CustomLinkViewSet(NetBoxModelViewSet): # Export templates # -class ExportTemplateViewSet(NetBoxModelViewSet): +class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): metadata_class = ContentTypeMetadata - queryset = ExportTemplate.objects.all() + queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file') serializer_class = serializers.ExportTemplateSerializer filterset_class = filtersets.ExportTemplateFilterSet diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 799e79123..f7f34e17a 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -127,10 +127,18 @@ class ExportTemplateFilterSet(BaseFilterSet): field_name='content_types__id' ) content_types = ContentTypeFilter() + data_source_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data source (ID)'), + ) + data_file_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data file (ID)'), + ) class Meta: model = ExportTemplate - fields = ['id', 'content_types', 'name', 'description'] + fields = ['id', 'content_types', 'name', 'description', 'data_synced'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 9256d104d..1c9294d34 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -21,9 +21,9 @@ from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', 'CustomFieldFilterForm', - 'JobResultFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', + 'JobResultFilterForm', 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', @@ -160,8 +160,22 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), + ('Data', ('data_source_id', 'data_file_id')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) + data_source_id = DynamicModelMultipleChoiceField( + queryset=DataSource.objects.all(), + required=False, + label=_('Data source') + ) + data_file_id = DynamicModelMultipleChoiceField( + queryset=DataFile.objects.all(), + required=False, + label=_('Data file'), + query_params={ + 'source_id': '$data_source_id' + } + ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 429c4140a..0ffc5117c 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -96,19 +96,28 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates') ) + template_code = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'class': 'font-monospace'}) + ) fieldsets = ( ('Export Template', ('name', 'content_types', 'description')), - ('Template', ('template_code',)), + ('Content', ('data_source', 'data_file', 'template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) class Meta: model = ExportTemplate fields = '__all__' - widgets = { - 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}), - } + + def clean(self): + super().clean() + + if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'): + raise forms.ValidationError("Must specify either local content or a data file") + + return self.cleaned_data class SavedFilterForm(BootstrapMixin, forms.ModelForm): @@ -261,8 +270,8 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): def clean(self): super().clean() - if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_source'): - raise forms.ValidationError("Must specify either local data or a data source") + if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'): + raise forms.ValidationError("Must specify either local data or a data file") return self.cleaned_data diff --git a/netbox/extras/migrations/0086_exporttemplate_synced_data.py b/netbox/extras/migrations/0086_exporttemplate_synced_data.py new file mode 100644 index 000000000..87de6b71c --- /dev/null +++ b/netbox/extras/migrations/0086_exporttemplate_synced_data.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.6 on 2023-02-08 22:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('extras', '0085_configcontext_synced_data'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='data_file', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_path', + field=models.CharField(blank=True, editable=False, max_length=1000), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_synced', + field=models.DateTimeField(blank=True, editable=False, null=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index df32d6ac4..63a1e199e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -26,7 +26,8 @@ from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models import ChangeLoggedModel from netbox.models.features import ( - CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, + CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin, + TagsMixin, WebhooksMixin, ) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -281,7 +282,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged } -class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='export_templates', @@ -335,6 +336,13 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): 'name': f'"{self.name}" is a reserved name. Please choose a different name.' }) + def sync_data(self): + """ + Synchronize template content from the designated DataFile (if any). + """ + self.template_code = self.data_file.data_as_string + self.data_synced = timezone.now() + def render(self, queryset): """ Render the contents of the template. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 51443ad87..6b2f34de4 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -90,15 +90,24 @@ class ExportTemplateTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() as_attachment = columns.BooleanColumn() + data_source = tables.Column( + linkify=True + ) + data_file = tables.Column( + linkify=True + ) + is_synced = columns.BooleanColumn( + verbose_name='Synced' + ) class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', - 'created', 'last_updated', + 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', ) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 6fd178284..dabb9f977 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'), path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'), path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), + path('export-templates/sync/', views.ExportTemplateBulkSyncDataView.as_view(), name='exporttemplate_bulk_sync'), path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), # Saved filters diff --git a/netbox/extras/views.py b/netbox/extras/views.py index c46890c19..de06b5739 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -121,6 +121,8 @@ class ExportTemplateListView(generic.ObjectListView): filterset = filtersets.ExportTemplateFilterSet filterset_form = forms.ExportTemplateFilterForm table = tables.ExportTemplateTable + template_name = 'extras/exporttemplate_list.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') @register_model_view(ExportTemplate) @@ -158,6 +160,10 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView): + queryset = ExportTemplate.objects.all() + + # # Saved filters # diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 3714b3f1c..e9513a3a8 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -50,10 +50,10 @@ {% endif %} - - Data Synced - {{ object.data_synced|placeholder }} - + + Data Synced + {{ object.data_synced|placeholder }} +
@@ -86,22 +86,8 @@ {% include 'extras/inc/configcontext_format.html' %}
- {% if object.data_file and object.data_file.last_updated > object.data_synced %} - - {% endif %} - {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} + {% include 'inc/sync_warning.html' %} + {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index d14294355..a80db8fca 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -10,66 +10,92 @@ {% endblock %} {% block content %} -
-
-
-
- Export Template -
-
- - - - - - - - - - - - - - - - - - - - - -
Name{{ object.name }}
Description{{ object.description|placeholder }}
MIME Type{{ object.mime_type|placeholder }}
File Extension{{ object.file_extension|placeholder }}
Attachment{% checkmark object.as_attachment %}
-
-
-
-
Assigned Models
-
- - {% for ct in object.content_types.all %} +
+
+
+
Export Template
+
+
- + + - {% endfor %} -
{{ ct }}Name{{ object.name }}
+ + Description + {{ object.description|placeholder }} + + + MIME Type + {{ object.mime_type|placeholder }} + + + File Extension + {{ object.file_extension|placeholder }} + + + Attachment + {% checkmark object.as_attachment %} + + + Data Source + + {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + + + Data File + + {% if object.data_file %} + {{ object.data_file }} + {% elif object.data_path %} +
+ +
+ {{ object.data_path }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + + + Data Synced + {{ object.data_synced|placeholder }} + + +
-
- {% plugin_left_page object %} -
-
-
-
- Template -
-
-
{{ object.template_code }}
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+ {% plugin_left_page object %} +
+
+
+
Template
+
+ {% include 'inc/sync_warning.html' %} +
{{ object.template_code }}
+
+
+ {% plugin_right_page object %}
- {% plugin_right_page object %}
-
-
+
- {% plugin_full_width_page object %} + {% plugin_full_width_page object %}
-
+
{% endblock %} diff --git a/netbox/templates/extras/exporttemplate_list.html b/netbox/templates/extras/exporttemplate_list.html new file mode 100644 index 000000000..c79f9259a --- /dev/null +++ b/netbox/templates/extras/exporttemplate_list.html @@ -0,0 +1,10 @@ +{% extends 'generic/object_list.html' %} + +{% block bulk_buttons %} + {% if perms.extras.sync_configcontext %} + + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/netbox/templates/inc/sync_warning.html b/netbox/templates/inc/sync_warning.html new file mode 100644 index 000000000..1ffc77e15 --- /dev/null +++ b/netbox/templates/inc/sync_warning.html @@ -0,0 +1,13 @@ +{% load buttons %} +{% load perms %} + +{% if object.data_file and object.data_file.last_updated > object.data_synced %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/sync.html b/netbox/utilities/templates/buttons/sync.html new file mode 100644 index 000000000..58f2b95cc --- /dev/null +++ b/netbox/utilities/templates/buttons/sync.html @@ -0,0 +1,6 @@ +
+ {% csrf_token %} + +
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index bcdb099d8..8a706ebeb 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -46,6 +46,16 @@ def delete_button(instance): } +@register.inclusion_tag('buttons/sync.html') +def sync_button(instance): + viewname = get_viewname(instance, 'sync') + url = reverse(viewname, kwargs={'pk': instance.pk}) + + return { + 'url': url, + } + + # # List buttons # diff --git a/netbox/utilities/templatetags/perms.py b/netbox/utilities/templatetags/perms.py index f1bbf7549..809c74ad1 100644 --- a/netbox/utilities/templatetags/perms.py +++ b/netbox/utilities/templatetags/perms.py @@ -28,3 +28,8 @@ def can_change(user, instance): @register.filter() def can_delete(user, instance): return _check_permission(user, instance, 'delete') + + +@register.filter() +def can_sync(user, instance): + return _check_permission(user, instance, 'sync') From 653edc3953149aa9418705ed89e7edea45326906 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Feb 2023 08:53:21 -0500 Subject: [PATCH 21/29] Merge migrations --- ...ext_synced_data.py => 0085_synced_data.py} | 24 +++++++++++-- .../0086_exporttemplate_synced_data.py | 35 ------------------- 2 files changed, 22 insertions(+), 37 deletions(-) rename netbox/extras/migrations/{0085_configcontext_synced_data.py => 0085_synced_data.py} (54%) delete mode 100644 netbox/extras/migrations/0086_exporttemplate_synced_data.py diff --git a/netbox/extras/migrations/0085_configcontext_synced_data.py b/netbox/extras/migrations/0085_synced_data.py similarity index 54% rename from netbox/extras/migrations/0085_configcontext_synced_data.py rename to netbox/extras/migrations/0085_synced_data.py index f3022665b..4790cd51a 100644 --- a/netbox/extras/migrations/0085_configcontext_synced_data.py +++ b/netbox/extras/migrations/0085_synced_data.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.6 on 2023-02-06 15:34 - from django.db import migrations, models import django.db.models.deletion @@ -12,6 +10,7 @@ class Migration(migrations.Migration): ] operations = [ + # ConfigContexts migrations.AddField( model_name='configcontext', name='data_file', @@ -32,4 +31,25 @@ class Migration(migrations.Migration): name='data_synced', field=models.DateTimeField(blank=True, editable=False, null=True), ), + # ExportTemplates + migrations.AddField( + model_name='exporttemplate', + name='data_file', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_path', + field=models.CharField(blank=True, editable=False, max_length=1000), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), + ), + migrations.AddField( + model_name='exporttemplate', + name='data_synced', + field=models.DateTimeField(blank=True, editable=False, null=True), + ), ] diff --git a/netbox/extras/migrations/0086_exporttemplate_synced_data.py b/netbox/extras/migrations/0086_exporttemplate_synced_data.py deleted file mode 100644 index 87de6b71c..000000000 --- a/netbox/extras/migrations/0086_exporttemplate_synced_data.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.1.6 on 2023-02-08 22:16 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0001_initial'), - ('extras', '0085_configcontext_synced_data'), - ] - - operations = [ - migrations.AddField( - model_name='exporttemplate', - name='data_file', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'), - ), - migrations.AddField( - model_name='exporttemplate', - name='data_path', - field=models.CharField(blank=True, editable=False, max_length=1000), - ), - migrations.AddField( - model_name='exporttemplate', - name='data_source', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), - ), - migrations.AddField( - model_name='exporttemplate', - name='data_synced', - field=models.DateTimeField(blank=True, editable=False, null=True), - ), - ] From 4c4020655d1a5fc1211298a2da1783bdb1592caa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Feb 2023 09:22:43 -0500 Subject: [PATCH 22/29] Changelog for #11693 --- docs/release-notes/version-3.5.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 985953d47..6df759f6c 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -10,6 +10,7 @@ * [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI * [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments * [#11625](https://github.com/netbox-community/netbox/issues/11625) - Add HTMX support to ObjectEditView +* [#11693](https://github.com/netbox-community/netbox/issues/11693) - Enable syncing export template content from remote sources ### Other Changes From a9e58bc80f1e6e40ff2eb3e3d2f26b4fbfd9bd5b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 10 Feb 2023 22:29:34 +0100 Subject: [PATCH 23/29] Fixes #11694 - Remove obsolete SmallTextarea widget --- netbox/circuits/forms/bulk_edit.py | 9 ++++----- netbox/core/forms/bulk_edit.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 24 ++++++++++++------------ netbox/dcim/forms/model_forms.py | 8 ++++---- netbox/ipam/forms/bulk_edit.py | 24 ++++++++++++------------ netbox/tenancy/forms/bulk_edit.py | 4 ++-- netbox/tenancy/forms/model_forms.py | 4 ++-- netbox/utilities/forms/widgets.py | 8 -------- netbox/virtualization/forms/bulk_edit.py | 6 +++--- netbox/wireless/forms/bulk_edit.py | 6 +++--- 10 files changed, 44 insertions(+), 53 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index e1fe6338d..dd6e103e4 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,8 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, - StaticSelect, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, ) __all__ = ( @@ -35,7 +34,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) @@ -63,7 +62,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) @@ -125,7 +124,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index c5713b626..6fb562db6 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -5,7 +5,7 @@ from core.choices import DataSourceTypeChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, CommentField, SmallTextarea, StaticSelect, + add_blank_choice, BulkEditNullBooleanSelect, CommentField, StaticSelect, ) __all__ = ( @@ -30,7 +30,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) parameters = forms.JSONField( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8969b1e69..d9770db40 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, + DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget, ) __all__ = ( @@ -138,7 +138,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -309,7 +309,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -345,7 +345,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -406,7 +406,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -441,7 +441,7 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -551,7 +551,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -594,7 +594,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -644,7 +644,7 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -668,7 +668,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -714,7 +714,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -776,7 +776,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 703a7a6b4..44e2e3526 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -12,7 +12,7 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup @@ -149,12 +149,12 @@ class SiteForm(TenancyForm, NetBoxModelForm): 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', ) widgets = { - 'physical_address': SmallTextarea( + 'physical_address': forms.Textarea( attrs={ 'rows': 3, } ), - 'shipping_address': SmallTextarea( + 'shipping_address': forms.Textarea( attrs={ 'rows': 3, } @@ -470,7 +470,7 @@ class PlatformForm(NetBoxModelForm): 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', ] widgets = { - 'napalm_args': SmallTextarea(), + 'napalm_args': forms.Textarea(), } diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index d0af43975..ed5ca53f5 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, - SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, + StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( @@ -48,7 +48,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -69,7 +69,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -116,7 +116,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -145,7 +145,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -227,7 +227,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -266,7 +266,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -314,7 +314,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -359,7 +359,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -442,7 +442,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -474,7 +474,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -504,7 +504,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 183a8e851..eda256a57 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea +from utilities.forms import CommentField, DynamicModelChoiceField __all__ = ( 'ContactBulkEditForm', @@ -106,7 +106,7 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index b466c94b2..e835194ff 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django import forms from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, SmallTextarea, StaticSelect, + BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, StaticSelect, ) __all__ = ( @@ -112,7 +112,7 @@ class ContactForm(NetBoxModelForm): 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { - 'address': SmallTextarea(attrs={'rows': 3}), + 'address': forms.Textarea(attrs={'rows': 3}), } diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 1802306f1..16ec72ecf 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -21,7 +21,6 @@ __all__ = ( 'SelectSpeedWidget', 'SelectWithPK', 'SlugWidget', - 'SmallTextarea', 'StaticSelect', 'StaticSelectMultiple', 'TimePicker', @@ -33,13 +32,6 @@ QueryParam = Dict[str, QueryParamValue] ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]] -class SmallTextarea(forms.Textarea): - """ - Subclass used for rendering a smaller textarea element. - """ - pass - - class SlugWidget(forms.TextInput): """ Subclass TextInput and add a slug regeneration button next to the form field. diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 14ae89c37..bce04ffc7 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect + DynamicModelMultipleChoiceField, StaticSelect ) from virtualization.choices import * from virtualization.models import * @@ -90,7 +90,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) @@ -163,7 +163,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label=_('Comments') ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index be54faf9e..c0e265270 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea +from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -74,7 +74,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) @@ -119,7 +119,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, + widget=forms.Textarea, label='Comments' ) From c99ecc2885530038b2fa9966a52f3a46c0046e34 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sat, 11 Feb 2023 16:16:06 -0500 Subject: [PATCH 24/29] Closes #11737: ChangeLoggedModel should inherit WebhooksMixin --- netbox/circuits/models/circuits.py | 3 --- netbox/dcim/models/device_component_templates.py | 3 +-- netbox/extras/models/configcontexts.py | 5 ++--- netbox/extras/models/customfields.py | 4 ++-- netbox/extras/models/models.py | 14 +++++++------- netbox/extras/models/tags.py | 4 ++-- netbox/ipam/models/fhrp.py | 3 +-- netbox/netbox/models/__init__.py | 2 +- netbox/tenancy/models/contacts.py | 3 +-- 9 files changed, 17 insertions(+), 24 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 8ef5761fd..53192d39d 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models @@ -10,7 +9,6 @@ from dcim.models import CabledObjectModel from netbox.models import ( ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin, ) -from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -132,7 +130,6 @@ class CircuitTermination( CustomFieldsMixin, CustomLinksMixin, TagsMixin, - WebhooksMixin, ChangeLoggedModel, CabledObjectModel ): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 3d2d32509..be17627fb 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -9,7 +9,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * 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 @@ -33,7 +32,7 @@ __all__ = ( ) -class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): +class ComponentTemplateModel(ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 7b6088324..eed8babcd 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -6,10 +6,9 @@ from django.utils import timezone from extras.querysets import ConfigContextQuerySet from netbox.models import ChangeLoggedModel -from netbox.models.features import SyncedDataMixin, WebhooksMixin +from netbox.models.features import SyncedDataMixin from utilities.utils import deepmerge - __all__ = ( 'ConfigContext', 'ConfigContextModel', @@ -20,7 +19,7 @@ __all__ = ( # Config contexts # -class ConfigContext(SyncedDataMixin, WebhooksMixin, ChangeLoggedModel): +class ConfigContext(SyncedDataMixin, 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 4842c0654..631185851 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -17,7 +17,7 @@ from django.utils.translation import gettext as _ from extras.choices import * from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel -from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.search import FieldTypes from utilities import filters from utilities.forms import ( @@ -54,7 +54,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomField(CloningMixin, ExportTemplatesMixin, 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 63a1e199e..1360904dc 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -27,7 +27,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models import ChangeLoggedModel from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin, - TagsMixin, WebhooksMixin, + TagsMixin, ) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -46,7 +46,7 @@ __all__ = ( ) -class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class Webhook(ExportTemplatesMixin, 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. @@ -203,7 +203,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return render_jinja2(self.payload_url, context) -class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomLink(CloningMixin, ExportTemplatesMixin, 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. @@ -282,7 +282,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged } -class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='export_templates', @@ -376,7 +376,7 @@ class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, WebhooksMixin, Chang return response -class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ A set of predefined keyword parameters that can be reused to filter for specific objects. """ @@ -447,7 +447,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge return qd.urlencode() -class ImageAttachment(WebhooksMixin, ChangeLoggedModel): +class ImageAttachment(ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -523,7 +523,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): return objectchange -class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ExportTemplatesMixin, ChangeLoggedModel): +class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplatesMixin, 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 diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 827d969e3..b980f0709 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -5,7 +5,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, WebhooksMixin +from netbox.models.features import ExportTemplatesMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -14,7 +14,7 @@ from utilities.fields import ColorField # Tags # -class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): +class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase): id = models.BigAutoField( primary_key=True ) diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 759a6e1d3..1044a5cde 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -5,7 +5,6 @@ from django.db import models from django.urls import reverse from netbox.models import ChangeLoggedModel, PrimaryModel -from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -73,7 +72,7 @@ class FHRPGroup(PrimaryModel): return reverse('ipam:fhrpgroup', args=[self.pk]) -class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): +class FHRPGroupAssignment(ChangeLoggedModel): interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index a4c8e0ec2..db8179fdc 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -38,7 +38,7 @@ class NetBoxFeatureSet( # Base model classes # -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin, models.Model): """ Base model for ancillary models; provides limited functionality for models which don't support NetBox's full feature set. diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 4fa8d87cb..440541b5f 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,7 +4,6 @@ from django.db import models from django.urls import reverse from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import WebhooksMixin from tenancy.choices import * __all__ = ( @@ -93,7 +92,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -class ContactAssignment(WebhooksMixin, ChangeLoggedModel): +class ContactAssignment(ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE From 40ad9bfb8c5776a9579c62851e88973d83bb0fab Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sat, 11 Feb 2023 16:30:17 -0500 Subject: [PATCH 25/29] Check for change records only if objects being deleted support change logging --- netbox/utilities/testing/views.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 0a16c4b3b..4a1b2207d 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -8,6 +8,7 @@ from django.urls import reverse from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange +from netbox.models.features import ChangeLoggingMixin from users.models import ObjectPermission from utilities.choices import ImportFormatChoices from .base import ModelTestCase @@ -350,12 +351,13 @@ class ViewTestCases: self._get_queryset().get(pk=instance.pk) # Verify ObjectChange creation - objectchanges = ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk - ) - self.assertEqual(len(objectchanges), 1) - self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) + if issubclass(instance.__class__, ChangeLoggingMixin): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): From fa6cad1b2cd1fd1f9b0ee1a1a686e137bd96f9af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sat, 11 Feb 2023 16:31:00 -0500 Subject: [PATCH 26/29] DataFile should not inherit from ChangeLoggingMixin --- netbox/core/migrations/0001_initial.py | 4 ++-- netbox/core/models/data.py | 11 +++++++---- netbox/core/tests/test_views.py | 1 - 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/core/migrations/0001_initial.py b/netbox/core/migrations/0001_initial.py index 803ac3b13..37c3b617e 100644 --- a/netbox/core/migrations/0001_initial.py +++ b/netbox/core/migrations/0001_initial.py @@ -43,9 +43,9 @@ class Migration(migrations.Migration): name='DataFile', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('created', models.DateTimeField(auto_now_add=True, null=True)), - ('path', models.CharField(editable=False, max_length=1000)), + ('created', models.DateTimeField(auto_now_add=True)), ('last_updated', models.DateTimeField(editable=False)), + ('path', models.CharField(editable=False, max_length=1000)), ('size', models.PositiveIntegerField(editable=False)), ('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])), ('data', models.BinaryField()), diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 4228c599c..67ab4a6c7 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -228,11 +228,17 @@ class DataSource(PrimaryModel): return False -class DataFile(ChangeLoggingMixin, models.Model): +class DataFile(models.Model): """ The database representation of a remote file fetched from a remote DataSource. DataFile instances should be created, updated, or deleted only by calling DataSource.sync(). """ + created = models.DateTimeField( + auto_now_add=True + ) + last_updated = models.DateTimeField( + editable=False + ) source = models.ForeignKey( to='core.DataSource', on_delete=models.CASCADE, @@ -244,9 +250,6 @@ class DataFile(ChangeLoggingMixin, models.Model): editable=False, help_text=_("File path relative to the data source's root") ) - last_updated = models.DateTimeField( - editable=False - ) size = models.PositiveIntegerField( editable=False ) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index fbee031ed..4a50a8d05 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -50,7 +50,6 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class DataFileTestCase( ViewTestCases.GetObjectViewTestCase, - ViewTestCases.GetObjectChangelogViewTestCase, ViewTestCases.DeleteObjectViewTestCase, ViewTestCases.ListObjectsViewTestCase, ViewTestCases.BulkDeleteObjectsViewTestCase, From 944bda59906cf0151dd86595b1bb1d9775eac45f Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 10 Feb 2023 21:57:23 +0100 Subject: [PATCH 27/29] Fixes #9653 - Add default_platform to DeviceType --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/filtersets.py | 10 ++++++++++ netbox/dcim/forms/bulk_edit.py | 6 +++++- netbox/dcim/forms/bulk_import.py | 9 +++++++-- netbox/dcim/forms/filtersets.py | 7 ++++++- netbox/dcim/forms/model_forms.py | 8 ++++++-- .../0169_devicetype_default_platform.py | 19 +++++++++++++++++++ netbox/dcim/models/devices.py | 15 ++++++++++++++- netbox/dcim/tables/devicetypes.py | 5 ++++- netbox/dcim/tests/test_filtersets.py | 18 ++++++++++++++++-- netbox/dcim/tests/test_views.py | 17 +++++++++++++++-- netbox/templates/dcim/devicetype.html | 4 ++++ 12 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0169_devicetype_default_platform.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f4f3ff5f..379d71b0d 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -309,6 +309,7 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + default_platform = NestedPlatformSerializer(required=False, allow_null=True) u_height = serializers.DecimalField( max_digits=4, decimal_places=1, @@ -324,7 +325,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', + 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 83ae8bcc9..774f8a41f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -436,6 +436,16 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label=_('Manufacturer (slug)'), ) + default_platform_id = django_filters.ModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label=_('Default platform (ID)'), + ) + default_platform = django_filters.ModelMultipleChoiceFilter( + field_name='default_platform__slug', + queryset=Platform.objects.all(), + to_field_name='slug', + label=_('Default platform (slug)'), + ) has_front_image = django_filters.BooleanFilter( label=_('Has a front image'), method='_has_front_image' diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d9770db40..e5b896f9f 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -374,6 +374,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): queryset=Manufacturer.objects.all(), required=False ) + default_platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) part_number = forms.CharField( required=False ) @@ -412,7 +416,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): model = DeviceType fieldsets = ( - ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), + ('Device Type', ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 3f016899e..1e8abcac6 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -281,12 +281,17 @@ class DeviceTypeImportForm(NetBoxModelImportForm): queryset=Manufacturer.objects.all(), to_field_name='name' ) + default_platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + to_field_name='name', + required=False, + ) class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'description', 'comments', + 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', + 'subdevice_role', 'airflow', 'description', 'comments', ] diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4dd2f73eb..7e2b4d2d8 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -378,7 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( (None, ('q', 'filter_id', 'tag')), - ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -391,6 +391,11 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Manufacturer') ) + default_platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Default platform') + ) part_number = forms.CharField( required=False ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 44e2e3526..14217d2d6 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -378,13 +378,17 @@ class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) + default_platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) slug = SlugField( slug_source='model' ) comments = CommentField() fieldsets = ( - ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')), + ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags', 'default_platform')), ('Chassis', ( 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', )), @@ -395,7 +399,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'default_platform' ] widgets = { 'airflow': StaticSelect(), diff --git a/netbox/dcim/migrations/0169_devicetype_default_platform.py b/netbox/dcim/migrations/0169_devicetype_default_platform.py new file mode 100644 index 000000000..a143f2c62 --- /dev/null +++ b/netbox/dcim/migrations/0169_devicetype_default_platform.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.6 on 2023-02-10 18:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0168_interface_template_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='default_platform', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 603129228..94f61aba7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -82,6 +82,14 @@ class DeviceType(PrimaryModel, WeightMixin): slug = models.SlugField( max_length=100 ) + default_platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Default platform' + ) part_number = models.CharField( max_length=50, blank=True, @@ -121,7 +129,7 @@ class DeviceType(PrimaryModel, WeightMixin): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' + 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) prerequisite_models = ( 'dcim.Manufacturer', @@ -165,6 +173,7 @@ class DeviceType(PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'slug': self.slug, + 'default_platform': self.default_platform.name if self.default_platform else None, 'part_number': self.part_number, 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, @@ -801,6 +810,10 @@ class Device(PrimaryModel, ConfigContextModel): if is_new and not self.airflow: self.airflow = self.device_type.airflow + # Inherit default_platform from DeviceType if not set + if is_new and not self.platform: + self.platform = self.device_type.default_platform + super().save(*args, **kwargs) # If this is a new Device, instantiate all the related components per the DeviceType definition diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index dff697588..91a37fab3 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -77,6 +77,9 @@ class DeviceTypeTable(NetBoxTable): manufacturer = tables.Column( linkify=True ) + default_platform = tables.Column( + linkify=True + ) is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) @@ -100,7 +103,7 @@ class DeviceTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 45d5797bd..c78b592d3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -699,9 +699,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ) Manufacturer.objects.bulk_create(manufacturers) + platforms = ( + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]), + ) + Platform.objects.bulk_create(platforms) + device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[0], default_platform=platforms[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[1], default_platform=platforms[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) DeviceType.objects.bulk_create(device_types) @@ -785,6 +792,13 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_default_platform(self): + platforms = Platform.objects.all()[:2] + params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'default_platform': [platforms[0].slug, platforms[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_front_image(self): params = {'has_front_image': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6ea935bc8..bba91412d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -503,6 +503,12 @@ class DeviceTypeTestCase( ) Manufacturer.objects.bulk_create(manufacturers) + platforms = ( + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]), + Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]), + ) + Platform.objects.bulk_create(platforms) + DeviceType.objects.bulk_create([ DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]), @@ -513,6 +519,7 @@ class DeviceTypeTestCase( cls.form_data = { 'manufacturer': manufacturers[1].pk, + 'default_platform': platforms[0].pk, 'model': 'Device Type X', 'slug': 'device-type-x', 'part_number': '123ABC', @@ -525,6 +532,7 @@ class DeviceTypeTestCase( cls.bulk_edit_data = { 'manufacturer': manufacturers[1].pk, + 'default_platform': platforms[1].pk, 'u_height': 3, 'is_full_depth': False, } @@ -673,6 +681,7 @@ class DeviceTypeTestCase( """ IMPORT_DATA = """ manufacturer: Generic +default_platform: Platform model: TEST-1000 slug: test-1000 u_height: 2 @@ -755,8 +764,11 @@ inventory-items: manufacturer: Generic """ - # Create the manufacturer - Manufacturer(name='Generic', slug='generic').save() + # Create the manufacturer and platform + manufacturer = Manufacturer(name='Generic', slug='generic') + manufacturer.save() + platform = Platform(name='Platform', slug='test-platform', manufacturer=manufacturer) + platform.save() # Add all required permissions to the test user self.add_permissions( @@ -783,6 +795,7 @@ inventory-items: device_type = DeviceType.objects.get(model='TEST-1000') self.assertEqual(device_type.comments, 'Test comment') + self.assertEqual(device_type.default_platform.pk, platform.pk) # Verify all of the components were created self.assertEqual(device_type.consoleporttemplates.count(), 3) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 73c82ddae..984898caa 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,6 +27,10 @@ Part Number {{ object.part_number|placeholder }} + + Default Platform + {{ object.default_platform|linkify }} + Description {{ object.description|placeholder }} From 959a2ab2c46a0b6a73b5feafcf711e3d93f45ad5 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 15 Feb 2023 12:55:25 +0100 Subject: [PATCH 28/29] Fix issues with the ContactAssignmentListView --- netbox/tenancy/filtersets.py | 12 ++++++++++++ netbox/tenancy/urls.py | 1 + netbox/tenancy/views.py | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index ab74949ff..1edc8fdc8 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -81,6 +81,10 @@ class ContactFilterSet(NetBoxModelFilterSet): class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) content_type = ContentTypeFilter() contact_id = django_filters.ModelMultipleChoiceFilter( queryset=Contact.objects.all(), @@ -101,6 +105,14 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): model = ContactAssignment fields = ['id', 'content_type_id', 'object_id', 'priority'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(contact__name__icontains=value) | + Q(role__name__icontains=value) + ) + class ContactModelFilterSet(django_filters.FilterSet): contact = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index cb8715f70..6563eff4b 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -49,6 +49,7 @@ urlpatterns = [ # Contact assignments path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'), path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), + path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'), path('contact-assignments//', include(get_model_urls('tenancy', 'contactassignment'))), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b7585b8d7..b71702d65 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -354,6 +354,7 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable + actions = ('export', 'bulk_delete') @register_model_view(ContactAssignment, 'edit') @@ -376,6 +377,12 @@ class ContactAssignmentEditView(generic.ObjectEditView): } +class ContactAssignmentBulkDeleteView(generic.BulkDeleteView): + queryset = ContactAssignment.objects.all() + filterset = filtersets.ContactAssignmentFilterSet + table = tables.ContactAssignmentTable + + @register_model_view(ContactAssignment, 'delete') class ContactAssignmentDeleteView(generic.ObjectDeleteView): queryset = ContactAssignment.objects.all() From d6c41fb45f28468ab5182bfe3acfb58897fc3db0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2023 10:25:51 -0500 Subject: [PATCH 29/29] Closes #11765: Remove StaticSelect & StaticSelectMultiple (#11767) * Remove StaticSelect, StaticSelectMultiple form widgets * Tag custom ChoiceField, MultipleChoiceField classes for removal in v3.6 --- docs/plugins/development/forms.md | 3 + netbox/circuits/forms/bulk_edit.py | 5 +- netbox/circuits/forms/filtersets.py | 4 +- netbox/circuits/forms/model_forms.py | 3 - netbox/core/forms/bulk_edit.py | 7 +- netbox/core/forms/filtersets.py | 10 +- netbox/core/forms/model_forms.py | 4 +- netbox/dcim/forms/bulk_edit.py | 100 +++++-------- netbox/dcim/forms/filtersets.py | 135 +++++++++--------- netbox/dcim/forms/model_forms.py | 105 +------------- netbox/extras/forms/bulk_edit.py | 8 +- netbox/extras/forms/filtersets.py | 41 +++--- netbox/extras/forms/model_forms.py | 12 +- netbox/extras/models/customfields.py | 12 +- netbox/ipam/forms/bulk_edit.py | 29 ++-- netbox/ipam/forms/filtersets.py | 47 +++--- netbox/ipam/forms/model_forms.py | 38 +---- netbox/netbox/forms/__init__.py | 8 +- netbox/tenancy/forms/filtersets.py | 5 +- netbox/tenancy/forms/model_forms.py | 3 +- netbox/users/forms.py | 4 +- .../utilities/forms/fields/content_types.py | 4 +- netbox/utilities/forms/fields/fields.py | 12 +- netbox/utilities/forms/forms.py | 17 ++- netbox/utilities/forms/widgets.py | 23 +-- netbox/virtualization/forms/bulk_edit.py | 9 +- netbox/virtualization/forms/filtersets.py | 10 +- netbox/virtualization/forms/model_forms.py | 13 +- netbox/wireless/forms/filtersets.py | 20 +-- netbox/wireless/forms/model_forms.py | 12 +- 30 files changed, 220 insertions(+), 483 deletions(-) diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index d819b76cb..db7adff23 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -170,6 +170,9 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c ## Choice Fields +!!! warning "Obsolete Fields" + NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6. + ::: utilities.forms.ChoiceField options: members: false diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index dd6e103e4..a3e91c8ae 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ) __all__ = ( @@ -100,8 +100,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(CircuitStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index d7cfc494d..05dacfd38 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField +from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -107,7 +107,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Provider network') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False ) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index cd73780fa..be0d39835 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -7,7 +7,6 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField, - StaticSelect, ) __all__ = ( @@ -102,7 +101,6 @@ class CircuitForm(TenancyForm, NetBoxModelForm): 'commit_rate': _("Committed rate"), } widgets = { - 'status': StaticSelect(), 'install_date': DatePicker(), 'termination_date': DatePicker(), 'commit_rate': SelectSpeedWidget(), @@ -174,7 +172,6 @@ class CircuitTerminationForm(NetBoxModelForm): 'pp_info': _("Patch panel ID and port number(s)") } widgets = { - 'term_side': StaticSelect(), 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index 6fb562db6..f613785c5 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -4,9 +4,7 @@ from django.utils.translation import gettext as _ from core.choices import DataSourceTypeChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm -from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, CommentField, StaticSelect, -) +from utilities.forms import add_blank_choice, BulkEditNullBooleanSelect, CommentField __all__ = ( 'DataSourceBulkEditForm', @@ -17,8 +15,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( choices=add_blank_choice(DataSourceTypeChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) enabled = forms.NullBooleanField( required=False, diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 433f07067..a54941537 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -4,9 +4,7 @@ from django.utils.translation import gettext as _ from core.choices import * from core.models import * from netbox.forms import NetBoxModelFilterSetForm -from utilities.forms import ( - BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, -) +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField __all__ = ( 'DataFileFilterForm', @@ -20,17 +18,17 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): (None, ('q', 'filter_id')), ('Data Source', ('type', 'status')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=DataSourceTypeChoices, required=False ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=DataSourceStatusChoices, required=False ) enabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 786e71c3a..e9cc962cd 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -3,7 +3,7 @@ import copy from django import forms from core.models import * -from netbox.forms import NetBoxModelForm, StaticSelect +from netbox.forms import NetBoxModelForm from netbox.registry import registry from utilities.forms import CommentField @@ -21,7 +21,7 @@ class DataSourceForm(NetBoxModelForm): 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', ] widgets = { - 'type': StaticSelect( + 'type': forms.Select( attrs={ 'hx-get': '.', 'hx-include': '#form_fields input', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e5b896f9f..c00359d4c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget, + DynamicModelMultipleChoiceField, form_from_model, SelectSpeedWidget, ) __all__ = ( @@ -96,8 +96,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(SiteStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -130,8 +129,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), - required=False, - widget=StaticSelect() + required=False ) description = forms.CharField( max_length=200, @@ -166,8 +164,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(LocationStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -238,8 +235,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(RackStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), @@ -256,13 +252,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(RackTypeChoices), - required=False, - widget=StaticSelect() + required=False ) width = forms.ChoiceField( choices=add_blank_choice(RackWidthChoices), - required=False, - widget=StaticSelect() + required=False ) u_height = forms.IntegerField( required=False, @@ -283,8 +277,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) outer_unit = forms.ChoiceField( choices=add_blank_choice(RackDimensionUnitChoices), - required=False, - widget=StaticSelect() + required=False ) mounting_depth = forms.IntegerField( required=False, @@ -301,8 +294,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): weight_unit = forms.ChoiceField( choices=add_blank_choice(WeightUnitChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) description = forms.CharField( max_length=200, @@ -333,8 +325,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): queryset=User.objects.order_by( 'username' ), - required=False, - widget=StaticSelect() + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -392,8 +383,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): ) airflow = forms.ChoiceField( choices=add_blank_choice(DeviceAirflowChoices), - required=False, - widget=StaticSelect() + required=False ) weight = forms.DecimalField( min_value=0, @@ -402,8 +392,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): weight_unit = forms.ChoiceField( choices=add_blank_choice(WeightUnitChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) description = forms.CharField( max_length=200, @@ -437,8 +426,7 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): weight_unit = forms.ChoiceField( choices=add_blank_choice(WeightUnitChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) description = forms.CharField( max_length=200, @@ -537,13 +525,11 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), - required=False, - widget=StaticSelect() + required=False ) airflow = forms.ChoiceField( choices=add_blank_choice(DeviceAirflowChoices), - required=False, - widget=StaticSelect() + required=False ) serial = forms.CharField( max_length=50, @@ -585,8 +571,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(ModuleStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) serial = forms.CharField( max_length=50, @@ -613,13 +598,11 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( choices=add_blank_choice(CableTypeChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) status = forms.ChoiceField( choices=add_blank_choice(LinkStatusChoices), required=False, - widget=StaticSelect(), initial='' ) tenant = DynamicModelChoiceField( @@ -640,8 +623,7 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): length_unit = forms.ChoiceField( choices=add_blank_choice(CableLengthUnitChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) description = forms.CharField( max_length=200, @@ -741,26 +723,22 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(PowerFeedStatusChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) type = forms.ChoiceField( choices=add_blank_choice(PowerFeedTypeChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) supply = forms.ChoiceField( choices=add_blank_choice(PowerFeedSupplyChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) phase = forms.ChoiceField( choices=add_blank_choice(PowerFeedPhaseChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) voltage = forms.IntegerField( required=False @@ -807,8 +785,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() + required=False ) nullable_fields = ('label', 'type', 'description') @@ -825,8 +802,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() + required=False ) description = forms.CharField( required=False @@ -846,8 +822,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() + required=False ) maximum_draw = forms.IntegerField( min_value=1, @@ -883,8 +858,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() + required=False ) power_port = forms.ModelChoiceField( queryset=PowerPortTemplate.objects.all(), @@ -892,8 +866,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): ) feed_leg = forms.ChoiceField( choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() + required=False ) description = forms.CharField( required=False @@ -924,8 +897,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(InterfaceTypeChoices), - required=False, - widget=StaticSelect() + required=False ) enabled = forms.NullBooleanField( required=False, @@ -943,14 +915,12 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): choices=add_blank_choice(InterfacePoEModeChoices), required=False, initial='', - widget=StaticSelect(), label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', - widget=StaticSelect(), label=_('PoE type') ) @@ -968,8 +938,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect() + required=False ) color = ColorField( required=False @@ -992,8 +961,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm): ) type = forms.ChoiceField( choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect() + required=False ) color = ColorField( required=False @@ -1208,14 +1176,12 @@ class InterfaceBulkEditForm( choices=add_blank_choice(InterfacePoEModeChoices), required=False, initial='', - widget=StaticSelect(), label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', - widget=StaticSelect(), label=_('PoE type') ) mark_connected = forms.NullBooleanField( @@ -1225,8 +1191,7 @@ class InterfaceBulkEditForm( mode = forms.ChoiceField( choices=add_blank_choice(InterfaceModeChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -1426,8 +1391,7 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( required=False, - choices=add_blank_choice(VirtualDeviceContextStatusChoices), - widget=StaticSelect() + choices=add_blank_choice(VirtualDeviceContextStatusChoices) ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 7e2b4d2d8..b5a6cd53b 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -10,8 +10,8 @@ from ipam.models import ASN, L2VPN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import ( - APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, - StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, + APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, + TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -150,7 +150,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False ) @@ -208,7 +208,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=LocationStatusChoices, required=False ) @@ -258,15 +258,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte }, label=_('Location') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=RackStatusChoices, required=False ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=RackTypeChoices, required=False ) - width = MultipleChoiceField( + width = forms.MultipleChoiceField( choices=RackWidthChoices, required=False ) @@ -399,88 +399,88 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): part_number = forms.CharField( required=False ) - subdevice_role = MultipleChoiceField( + subdevice_role = forms.MultipleChoiceField( choices=add_blank_choice(SubdeviceRoleChoices), required=False ) - airflow = MultipleChoiceField( + airflow = forms.MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), required=False ) has_front_image = forms.NullBooleanField( required=False, label='Has a front image', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) has_rear_image = forms.NullBooleanField( required=False, label='Has a rear image', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) console_server_ports = forms.NullBooleanField( required=False, label='Has console server ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_ports = forms.NullBooleanField( required=False, label='Has power ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_outlets = forms.NullBooleanField( required=False, label='Has power outlets', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) interfaces = forms.NullBooleanField( required=False, label='Has interfaces', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) pass_through_ports = forms.NullBooleanField( required=False, label='Has pass-through ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) device_bays = forms.NullBooleanField( required=False, label='Has device bays', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) module_bays = forms.NullBooleanField( required=False, label='Has module bays', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) inventory_items = forms.NullBooleanField( required=False, label='Has inventory items', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -517,42 +517,42 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): console_ports = forms.NullBooleanField( required=False, label='Has console ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) console_server_ports = forms.NullBooleanField( required=False, label='Has console server ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_ports = forms.NullBooleanField( required=False, label='Has power ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_outlets = forms.NullBooleanField( required=False, label='Has power outlets', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) interfaces = forms.NullBooleanField( required=False, label='Has interfaces', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) pass_through_ports = forms.NullBooleanField( required=False, label='Has pass-through ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -662,11 +662,11 @@ class DeviceFilterForm( null_option='None', label=_('Platform') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=DeviceStatusChoices, required=False ) - airflow = MultipleChoiceField( + airflow = forms.MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), required=False ) @@ -683,56 +683,56 @@ class DeviceFilterForm( has_primary_ip = forms.NullBooleanField( required=False, label='Has a primary IP', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) virtual_chassis_member = forms.NullBooleanField( required=False, label='Virtual chassis member', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) console_server_ports = forms.NullBooleanField( required=False, label='Has console server ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_ports = forms.NullBooleanField( required=False, label='Has power ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) power_outlets = forms.NullBooleanField( required=False, label='Has power outlets', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) interfaces = forms.NullBooleanField( required=False, label='Has interfaces', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) pass_through_ports = forms.NullBooleanField( required=False, label='Has pass-through ports', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -755,14 +755,14 @@ class VirtualDeviceContextFilterForm( label=_('Device'), fetch_trigger='open' ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( required=False, choices=add_blank_choice(VirtualDeviceContextStatusChoices) ) has_primary_ip = forms.NullBooleanField( required=False, label='Has a primary IP', - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -790,7 +790,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo label=_('Type'), fetch_trigger='open' ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=ModuleStatusChoices, required=False ) @@ -883,11 +883,11 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Device') ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=add_blank_choice(CableTypeChoices), required=False ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( required=False, choices=add_blank_choice(LinkStatusChoices) ) @@ -985,24 +985,21 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): }, label=_('Rack') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=PowerFeedStatusChoices, required=False ) type = forms.ChoiceField( choices=add_blank_choice(PowerFeedTypeChoices), - required=False, - widget=StaticSelect() + required=False ) supply = forms.ChoiceField( choices=add_blank_choice(PowerFeedSupplyChoices), - required=False, - widget=StaticSelect() + required=False ) phase = forms.ChoiceField( choices=add_blank_choice(PowerFeedPhaseChoices), - required=False, - widget=StaticSelect() + required=False ) voltage = forms.IntegerField( required=False @@ -1023,13 +1020,13 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): class CabledFilterForm(forms.Form): cabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) occupied = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -1038,7 +1035,7 @@ class CabledFilterForm(forms.Form): class PathEndpointFilterForm(CabledFilterForm): connected = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -1052,11 +1049,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, required=False ) - speed = MultipleChoiceField( + speed = forms.MultipleChoiceField( choices=ConsolePortSpeedChoices, required=False ) @@ -1071,11 +1068,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, required=False ) - speed = MultipleChoiceField( + speed = forms.MultipleChoiceField( choices=ConsolePortSpeedChoices, required=False ) @@ -1090,7 +1087,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=PowerPortTypeChoices, required=False ) @@ -1105,7 +1102,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=PowerOutletTypeChoices, required=False ) @@ -1132,11 +1129,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): }, label=_('Virtual Device Context') ) - kind = MultipleChoiceField( + kind = forms.MultipleChoiceField( choices=InterfaceKindChoices, required=False ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=InterfaceTypeChoices, required=False ) @@ -1145,19 +1142,19 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): label='Speed', widget=SelectSpeedWidget() ) - duplex = MultipleChoiceField( + duplex = forms.MultipleChoiceField( choices=InterfaceDuplexChoices, required=False ) enabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) mgmt_only = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -1169,22 +1166,22 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): required=False, label='WWN' ) - poe_mode = MultipleChoiceField( + poe_mode = forms.MultipleChoiceField( choices=InterfacePoEModeChoices, required=False, label='PoE mode' ) - poe_type = MultipleChoiceField( + poe_type = forms.MultipleChoiceField( choices=InterfacePoETypeChoices, required=False, label='PoE type' ) - rf_role = MultipleChoiceField( + rf_role = forms.MultipleChoiceField( choices=WirelessRoleChoices, required=False, label='Wireless role' ) - rf_channel = MultipleChoiceField( + rf_channel = forms.MultipleChoiceField( choices=WirelessChannelChoices, required=False, label='Wireless channel' @@ -1224,7 +1221,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('Cable', ('cabled', 'occupied')), ) model = FrontPort - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=PortTypeChoices, required=False ) @@ -1242,7 +1239,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=PortTypeChoices, required=False ) @@ -1301,7 +1298,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): ) discovered = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 14217d2d6..8bac5d342 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -13,7 +13,7 @@ from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, - SlugField, StaticSelect, SelectSpeedWidget, + SlugField, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -129,8 +129,7 @@ class SiteForm(TenancyForm, NetBoxModelForm): slug = SlugField() time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), - required=False, - widget=StaticSelect() + required=False ) comments = CommentField() @@ -159,8 +158,6 @@ class SiteForm(TenancyForm, NetBoxModelForm): 'rows': 3, } ), - 'status': StaticSelect(), - 'time_zone': StaticSelect(), } help_texts = { 'name': _("Full name of the site"), @@ -218,9 +215,6 @@ class LocationForm(TenancyForm, NetBoxModelForm): 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags', ) - widgets = { - 'status': StaticSelect(), - } class RackRoleForm(NetBoxModelForm): @@ -287,13 +281,6 @@ class RackForm(TenancyForm, NetBoxModelForm): 'facility_id': _("The unique rack ID assigned by the facility"), 'u_height': _("Height in rack units"), } - widgets = { - 'status': StaticSelect(), - 'type': StaticSelect(), - 'width': StaticSelect(), - 'outer_unit': StaticSelect(), - 'weight_unit': StaticSelect(), - } class RackReservationForm(TenancyForm, NetBoxModelForm): @@ -340,8 +327,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): user = forms.ModelChoiceField( queryset=User.objects.order_by( 'username' - ), - widget=StaticSelect() + ) ) comments = CommentField() @@ -402,15 +388,12 @@ class DeviceTypeForm(NetBoxModelForm): 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'default_platform' ] widgets = { - 'airflow': StaticSelect(), - 'subdevice_role': StaticSelect(), 'front_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS }), 'rear_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS }), - 'weight_unit': StaticSelect(), } @@ -431,10 +414,6 @@ class ModuleTypeForm(NetBoxModelForm): 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] - widgets = { - 'weight_unit': StaticSelect(), - } - class DeviceRoleForm(NetBoxModelForm): slug = SlugField() @@ -601,13 +580,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'local_context_data': _("Local config context data overwrites all source contexts in the final rendered " "config context"), } - widgets = { - 'face': StaticSelect(), - 'status': StaticSelect(), - 'airflow': StaticSelect(), - 'primary_ip4': StaticSelect(), - 'primary_ip6': StaticSelect(), - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -741,11 +713,6 @@ class CableForm(TenancyForm, NetBoxModelForm): 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] - widgets = { - 'status': StaticSelect, - 'type': StaticSelect, - 'length_unit': StaticSelect, - } error_messages = { 'length': { 'max_value': 'Maximum length is 32767 (any unit)' @@ -860,12 +827,6 @@ class PowerFeedForm(NetBoxModelForm): 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ] - widgets = { - 'status': StaticSelect(), - 'type': StaticSelect(), - 'supply': StaticSelect(), - 'phase': StaticSelect(), - } # @@ -1029,9 +990,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] - widgets = { - 'type': StaticSelect, - } class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): @@ -1044,9 +1002,6 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] - widgets = { - 'type': StaticSelect, - } class PowerPortTemplateForm(ModularComponentTemplateForm): @@ -1061,9 +1016,6 @@ class PowerPortTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] - widgets = { - 'type': StaticSelect(), - } class PowerOutletTemplateForm(ModularComponentTemplateForm): @@ -1084,10 +1036,6 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] - widgets = { - 'type': StaticSelect(), - 'feed_leg': StaticSelect(), - } class InterfaceTemplateForm(ModularComponentTemplateForm): @@ -1101,11 +1049,6 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', ] - widgets = { - 'type': StaticSelect(), - 'poe_mode': StaticSelect(), - 'poe_type': StaticSelect(), - } class FrontPortTemplateForm(ModularComponentTemplateForm): @@ -1131,9 +1074,6 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', ] - widgets = { - 'type': StaticSelect(), - } class RearPortTemplateForm(ModularComponentTemplateForm): @@ -1146,9 +1086,6 @@ class RearPortTemplateForm(ModularComponentTemplateForm): fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] - widgets = { - 'type': StaticSelect(), - } class ModuleBayTemplateForm(ComponentTemplateForm): @@ -1256,10 +1193,6 @@ class ConsolePortForm(ModularDeviceComponentForm): fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - 'speed': StaticSelect(), - } class ConsoleServerPortForm(ModularDeviceComponentForm): @@ -1275,10 +1208,6 @@ class ConsoleServerPortForm(ModularDeviceComponentForm): fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - 'speed': StaticSelect(), - } class PowerPortForm(ModularDeviceComponentForm): @@ -1296,9 +1225,6 @@ class PowerPortForm(ModularDeviceComponentForm): 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - } class PowerOutletForm(ModularDeviceComponentForm): @@ -1323,10 +1249,6 @@ class PowerOutletForm(ModularDeviceComponentForm): 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - 'feed_leg': StaticSelect(), - } class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): @@ -1431,14 +1353,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'type': StaticSelect(), 'speed': SelectSpeedWidget(), - 'poe_mode': StaticSelect(), - 'poe_type': StaticSelect(), - 'duplex': StaticSelect(), - 'mode': StaticSelect(), - 'rf_role': StaticSelect(), - 'rf_channel': StaticSelect(), } labels = { 'mode': '802.1Q Mode', @@ -1471,9 +1386,6 @@ class FrontPortForm(ModularDeviceComponentForm): 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - } class RearPortForm(ModularDeviceComponentForm): @@ -1488,9 +1400,6 @@ class RearPortForm(ModularDeviceComponentForm): fields = [ 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] - widgets = { - 'type': StaticSelect(), - } class ModuleBayForm(DeviceComponentForm): @@ -1521,8 +1430,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), label=_('Child Device'), - help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."), - widget=StaticSelect(), + help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.") ) def __init__(self, device_bay, *args, **kwargs): @@ -1771,8 +1679,3 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): 'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags' ] - widgets = { - 'status': StaticSelect(), - 'primary_ip4': StaticSelect(), - 'primary_ip6': StaticSelect(), - } diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 6e245bcaf..47a529772 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from extras.choices import * from extras.models import * from utilities.forms import ( - add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect, + add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ) __all__ = ( @@ -41,8 +41,7 @@ class CustomFieldBulkEditForm(BulkEditForm): label=_("UI visibility"), choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, - initial='', - widget=StaticSelect() + initial='' ) nullable_fields = ('group_name', 'description',) @@ -66,8 +65,7 @@ class CustomLinkBulkEditForm(BulkEditForm): ) button_class = forms.ChoiceField( choices=add_blank_choice(CustomLinkButtonClassChoices), - required=False, - widget=StaticSelect() + required=False ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1c9294d34..685eac9ba 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -12,8 +12,8 @@ from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, - ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, - StaticSelect, TagFilterField, + ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, + TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType from .mixins import SavedFiltersMixin @@ -44,7 +44,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Object type') ) - type = MultipleChoiceField( + type = forms.MultipleChoiceField( choices=CustomFieldTypeChoices, required=False, label=_('Field type') @@ -57,15 +57,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): ) required = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) ui_visibility = forms.ChoiceField( choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, - label=_('UI visibility'), - widget=StaticSelect() + label=_('UI visibility') ) @@ -84,7 +83,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work required=False, ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=JobResultStatusChoices, required=False ) @@ -142,13 +141,13 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): ) enabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) new_window = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -190,7 +189,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): ) as_attachment = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -208,13 +207,13 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): ) enabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) shared = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -235,32 +234,32 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Object type') ) - http_method = MultipleChoiceField( + http_method = forms.MultipleChoiceField( choices=WebhookHttpMethodChoices, required=False, label=_('HTTP method') ) enabled = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) type_create = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) type_update = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) type_delete = forms.NullBooleanField( required=False, - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -369,7 +368,7 @@ class LocalConfigContextFilterForm(forms.Form): local_context_data = forms.NullBooleanField( required=False, label=_('Has local config context data'), - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -410,8 +409,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): ) kind = forms.ChoiceField( choices=add_blank_choice(JournalEntryKindChoices), - required=False, - widget=StaticSelect() + required=False ) tag = TagFilterField(model) @@ -435,8 +433,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): ) action = forms.ChoiceField( choices=add_blank_choice(ObjectChangeActionChoices), - required=False, - widget=StaticSelect() + required=False ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 0ffc5117c..69c124ee2 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -12,7 +12,7 @@ from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, - DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, + DynamicModelMultipleChoiceField, JSONField, SlugField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -58,11 +58,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): 'type': _("The type of data stored in this field. For object/multi-object fields, select the related object " "type below.") } - widgets = { - 'type': StaticSelect(), - 'filter_logic': StaticSelect(), - 'ui_visibility': StaticSelect(), - } class CustomLinkForm(BootstrapMixin, forms.ModelForm): @@ -80,7 +75,6 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): model = CustomLink fields = '__all__' widgets = { - 'button_class': StaticSelect(), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), } @@ -172,7 +166,6 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): 'type_delete': 'Deletions', } widgets = { - 'http_method': StaticSelect(), 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}), @@ -288,8 +281,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class JournalEntryForm(NetBoxModelForm): kind = forms.ChoiceField( choices=add_blank_choice(JournalEntryKindChoices), - required=False, - widget=StaticSelect() + required=False ) comments = CommentField() diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 631185851..d482d7f8d 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -22,7 +22,7 @@ from netbox.search import FieldTypes from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, + JSONField, LaxURLField, add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -372,7 +372,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): (False, 'False'), ) field = forms.NullBooleanField( - required=required, initial=initial, widget=StaticSelect(choices=choices) + required=required, initial=initial, widget=forms.Select(choices=choices) ) # Date @@ -393,14 +393,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): if self.type == CustomFieldTypeChoices.TYPE_SELECT: field_class = CSVChoiceField if for_csv_import else forms.ChoiceField - field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect() - ) + field = field_class(choices=choices, required=required, initial=initial) else: field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField - field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() - ) + field = field_class(choices=choices, required=required, initial=initial) # URL elif self.type == CustomFieldTypeChoices.TYPE_URL: diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index ed5ca53f5..e63b34d75 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -9,8 +9,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, - StaticSelect, DynamicModelMultipleChoiceField, + add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + NumericArrayField, ) __all__ = ( @@ -205,8 +205,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( choices=add_blank_choice(PrefixStatusChoices), - required=False, - widget=StaticSelect() + required=False ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -254,8 +253,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( choices=add_blank_choice(IPRangeStatusChoices), - required=False, - widget=StaticSelect() + required=False ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -296,13 +294,11 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( choices=add_blank_choice(IPAddressStatusChoices), - required=False, - widget=StaticSelect() + required=False ) role = forms.ChoiceField( choices=add_blank_choice(IPAddressRoleChoices), - required=False, - widget=StaticSelect() + required=False ) dns_name = forms.CharField( max_length=255, @@ -331,8 +327,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): protocol = forms.ChoiceField( choices=add_blank_choice(FHRPGroupProtocolChoices), - required=False, - widget=StaticSelect() + required=False ) group_id = forms.IntegerField( min_value=0, @@ -342,7 +337,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): auth_type = forms.ChoiceField( choices=add_blank_choice(FHRPGroupAuthTypeChoices), required=False, - widget=StaticSelect(), label=_('Authentication type') ) auth_key = forms.CharField( @@ -430,8 +424,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ) status = forms.ChoiceField( choices=add_blank_choice(VLANStatusChoices), - required=False, - widget=StaticSelect() + required=False ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -459,8 +452,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), - required=False, - widget=StaticSelect() + required=False ) ports = NumericArrayField( base_field=forms.IntegerField( @@ -492,8 +484,7 @@ class ServiceBulkEditForm(ServiceTemplateBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( choices=add_blank_choice(L2VPNTypeChoices), - required=False, - widget=StaticSelect() + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 7e790a68a..1d505a168 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine @@ -87,7 +87,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm): is_private = forms.NullBooleanField( required=False, label=_('Private'), - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -104,8 +104,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() + label=_('Address family') ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), @@ -164,10 +163,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() + label=_('Address family') ) - mask_length = MultipleChoiceField( + mask_length = forms.MultipleChoiceField( required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label=_('Mask length') @@ -183,7 +181,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('Present in VRF') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=PrefixStatusChoices, required=False ) @@ -215,14 +213,14 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): is_pool = forms.NullBooleanField( required=False, label=_('Is a pool'), - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) mark_utilized = forms.NullBooleanField( required=False, label=_('Marked as 100% utilized'), - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -239,8 +237,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() + label=_('Address family') ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -248,7 +245,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Assigned VRF'), null_option='Global' ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=IPRangeStatusChoices, required=False ) @@ -282,14 +279,12 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() + label=_('Address family') ) mask_length = forms.ChoiceField( required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, - label=_('Mask length'), - widget=StaticSelect() + label=_('Mask length') ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -312,18 +307,18 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('Assigned VM'), ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=IPAddressStatusChoices, required=False ) - role = MultipleChoiceField( + role = forms.MultipleChoiceField( choices=IPAddressRoleChoices, required=False ) assigned_to_interface = forms.NullBooleanField( required=False, label=_('Assigned to an interface'), - widget=StaticSelect( + widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -340,7 +335,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): name = forms.CharField( required=False ) - protocol = MultipleChoiceField( + protocol = forms.MultipleChoiceField( choices=FHRPGroupProtocolChoices, required=False ) @@ -349,7 +344,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): required=False, label='Group ID' ) - auth_type = MultipleChoiceField( + auth_type = forms.MultipleChoiceField( choices=FHRPGroupAuthTypeChoices, required=False, label='Authentication type' @@ -444,7 +439,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('VLAN group') ) - status = MultipleChoiceField( + status = forms.MultipleChoiceField( choices=VLANStatusChoices, required=False ) @@ -474,8 +469,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), - required=False, - widget=StaticSelect() + required=False ) port = forms.IntegerField( required=False, @@ -497,8 +491,7 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ) type = forms.ChoiceField( choices=add_blank_choice(L2VPNTypeChoices), - required=False, - widget=StaticSelect() + required=False ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 430a4b2f8..4e50c4949 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -13,7 +13,7 @@ from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation from utilities.forms import ( add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, + DynamicModelMultipleChoiceField, NumericArrayField, SlugField, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -254,9 +254,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm): 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] - widgets = { - 'status': StaticSelect(), - } class IPRangeForm(TenancyForm, NetBoxModelForm): @@ -282,9 +279,6 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] - widgets = { - 'status': StaticSelect(), - } class IPAddressForm(TenancyForm, NetBoxModelForm): @@ -411,10 +405,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] - widgets = { - 'status': StaticSelect(), - 'role': StaticSelect(), - } def __init__(self, *args, **kwargs): @@ -510,10 +500,6 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): fields = [ 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags', ] - widgets = { - 'status': StaticSelect(), - 'role': StaticSelect(), - } class IPAddressAssignForm(BootstrapMixin, forms.Form): @@ -559,11 +545,6 @@ class FHRPGroupForm(NetBoxModelForm): 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description', 'comments', 'tags', ) - widgets = { - 'protocol': StaticSelect(), - 'auth_type': StaticSelect(), - 'ip_status': StaticSelect(), - } def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) @@ -700,9 +681,6 @@ class VLANGroupForm(NetBoxModelForm): 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags', ] - widgets = { - 'scope_type': StaticSelect, - } def __init__(self, *args, **kwargs): instance = kwargs.get('instance') @@ -740,7 +718,6 @@ class VLANForm(TenancyForm, NetBoxModelForm): ('virtualization.cluster', 'Cluster'), ), required=False, - widget=StaticSelect, label=_('Group scope') ) group = DynamicModelChoiceField( @@ -800,9 +777,6 @@ class VLANForm(TenancyForm, NetBoxModelForm): 'status': _("Operational status of this VLAN"), 'role': _("The primary function of this VLAN"), } - widgets = { - 'status': StaticSelect(), - } class ServiceTemplateForm(NetBoxModelForm): @@ -824,9 +798,6 @@ class ServiceTemplateForm(NetBoxModelForm): class Meta: model = ServiceTemplate fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') - widgets = { - 'protocol': StaticSelect(), - } class ServiceForm(NetBoxModelForm): @@ -865,10 +836,6 @@ class ServiceForm(NetBoxModelForm): 'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be " "reachable via all IPs assigned to the device."), } - widgets = { - 'protocol': StaticSelect(), - 'ipaddresses': StaticSelectMultiple(), - } class ServiceCreateForm(ServiceForm): @@ -934,9 +901,6 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', 'comments', 'tags' ) - widgets = { - 'type': StaticSelect(), - } class L2VPNTerminationForm(NetBoxModelForm): diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index d8acef94c..65460ebf1 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext as _ from netbox.search import LookupTypes from netbox.search.backends import search_backend -from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple +from utilities.forms import BootstrapMixin from .base import * @@ -32,14 +32,12 @@ class SearchForm(BootstrapMixin, forms.Form): obj_types = forms.MultipleChoiceField( choices=[], required=False, - label=_('Object type(s)'), - widget=StaticSelectMultiple() + label=_('Object type(s)') ) lookup = forms.ChoiceField( choices=LOOKUP_CHOICES, initial=LookupTypes.PARTIAL, - required=False, - widget=StaticSelect() + required=False ) def __init__(self, *args, **kwargs): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 7f843d9a4..626d26785 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -7,7 +8,7 @@ from tenancy.choices import * from tenancy.models import * from tenancy.forms import ContactModelFilterForm from utilities.forms.fields import ( - ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField, + ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) __all__ = ( @@ -106,7 +107,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Role') ) - priority = MultipleChoiceField( + priority = forms.MultipleChoiceField( choices=ContactPriorityChoices, required=False ) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index e835194ff..a27e41f74 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django import forms from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, StaticSelect, + BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, ) __all__ = ( @@ -142,5 +142,4 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): widgets = { 'content_type': forms.HiddenInput(), 'object_id': forms.HiddenInput(), - 'priority': StaticSelect(), } diff --git a/netbox/users/forms.py b/netbox/users/forms.py index e8647aa5f..0c7d7ea19 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext as _ from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES -from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect +from utilities.forms import BootstrapMixin, DateTimePicker from utilities.utils import flatten_dict from .models import Token, UserConfig @@ -35,7 +35,7 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): 'help_text': mark_safe(help_text), 'coerce': preference.coerce, 'required': False, - 'widget': StaticSelect, + 'widget': forms.Select, } preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) attrs.update(preference_fields) diff --git a/netbox/utilities/forms/fields/content_types.py b/netbox/utilities/forms/fields/content_types.py index 80861166c..76efe9a7b 100644 --- a/netbox/utilities/forms/fields/content_types.py +++ b/netbox/utilities/forms/fields/content_types.py @@ -27,11 +27,11 @@ class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): """ Selection field for a single content type. """ - widget = widgets.StaticSelect + pass class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): """ Selection field for one or more content types. """ - widget = widgets.StaticSelectMultiple + pass diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index bb6c3f73b..c5d2d0a1f 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -68,7 +68,6 @@ class TagFilterField(forms.MultipleChoiceField): :param model: The model of the filter """ - widget = widgets.StaticSelectMultiple def __init__(self, model, *args, **kwargs): def get_choices(): @@ -137,13 +136,16 @@ class MACAddressField(forms.Field): class ChoiceField(forms.ChoiceField): """ - Overrides Django's built-in `ChoiceField` to use NetBox's `StaticSelect` widget + Previously used to override Django's built-in `ChoiceField` to use NetBox's now-obsolete `StaticSelect` widget. """ - widget = widgets.StaticSelect + # TODO: Remove in v3.6 + pass class MultipleChoiceField(forms.MultipleChoiceField): """ - Overrides Django's built-in `MultipleChoiceField` to use NetBox's `StaticSelectMultiple` widget + Previously used to override Django's built-in `MultipleChoiceField` to use NetBox's now-obsolete + `StaticSelectMultiple` widget. """ - widget = widgets.StaticSelectMultiple + # TODO: Remove in v3.6 + pass diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 99d03f2a6..a640189e1 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext as _ from utilities.choices import ImportFormatChoices from utilities.forms.utils import parse_csv -from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect +from .widgets import APISelect, APISelectMultiple, ClearableFileInput __all__ = ( 'BootstrapMixin', @@ -37,27 +37,27 @@ class BootstrapMixin: super().__init__(*args, **kwargs) exempt_widgets = [ - forms.CheckboxInput, forms.FileInput, forms.RadioSelect, - forms.Select, APISelect, APISelectMultiple, ClearableFileInput, - StaticSelect, ] for field_name, field in self.fields.items(): css = field.widget.attrs.get('class', '') - if field.widget.__class__ not in exempt_widgets: - field.widget.attrs['class'] = f'{css} form-control' + if field.widget.__class__ in exempt_widgets: + continue elif isinstance(field.widget, forms.CheckboxInput): field.widget.attrs['class'] = f'{css} form-check-input' elif isinstance(field.widget, forms.Select): - field.widget.attrs['class'] = f'{css} form-select' + field.widget.attrs['class'] = f'{css} netbox-static-select' + + else: + field.widget.attrs['class'] = f'{css} form-control' if field.required and not isinstance(field.widget, forms.FileInput): field.widget.attrs['required'] = 'required' @@ -165,8 +165,7 @@ class ImportForm(BootstrapMixin, forms.Form): ) format = forms.ChoiceField( choices=ImportFormatChoices, - initial=ImportFormatChoices.AUTO, - widget=StaticSelect() + initial=ImportFormatChoices.AUTO ) data_field = 'data' diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 16ec72ecf..c7e1cfb81 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -21,8 +21,6 @@ __all__ = ( 'SelectSpeedWidget', 'SelectWithPK', 'SlugWidget', - 'StaticSelect', - 'StaticSelectMultiple', 'TimePicker', ) @@ -68,26 +66,7 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect): self.attrs['class'] = 'netbox-static-select' -class StaticSelect(forms.Select): - """ - A static