diff --git a/.github/stale.yml b/.github/stale.yml index 61201cc4e..43401de8a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,5 +1,8 @@ # Configuration for Stale (https://github.com/apps/stale) +# Pull requests are exempt from being marked as stale +only: issues + # Number of days of inactivity before an issue becomes stale daysUntilStale: 14 diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 6fac5b63d..cf98a6290 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -177,10 +177,11 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a All variables support the following default options: -* `label` - The name of the form field -* `description` - A brief description of the field * `default` - The field's default value +* `description` - A brief description of the field +* `label` - The name of the form field * `required` - Indicates whether the field is mandatory (default: true) +* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/)) ## Example diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 8cadddeb5..cbe01728c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -109,6 +109,20 @@ In order to send email, NetBox needs an email server configured. The following i * TIMEOUT - Amount of time to wait for a connection (seconds) * FROM_EMAIL - Sender address for emails sent by NetBox +Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail): + +``` +# python ./manage.py nbshell +>>> from django.core.mail import send_mail +>>> send_mail( + 'Test Email Subject', + 'Test Email Body', + 'noreply-netbox@example.com', + ['users@example.com'], + fail_silently=False +) +``` + --- ## EXEMPT_VIEW_PERMISSIONS diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dd7492cb4..81790eae0 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv * `PASSWORD` - PostgreSQL password * `HOST` - Name or IP address of the database server (use `localhost` if running locally) * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432) -* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)). +* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended) Example: @@ -36,6 +36,9 @@ DATABASE = { } ``` +!!! note + NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). + --- ## REDIS @@ -85,6 +88,48 @@ REDIS = { It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the same Redis instance for both may result in webhook processing data being lost during cache flushing events. +### Using Redis Sentinel + +If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal +configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from +above and the addition of two new keys. + +* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address +of the Redis server and port for each sentinel instance to connect to +* `SENTINEL_SERVICE`: Name of the master / service to connect to + +Example: + +```python +REDIS = { + 'webhooks': { + 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + }, + 'caching': { + 'SENTINELS': [ + ('mysentinel.redis.example.com', 6379), + ('othersentinel.redis.example.com', 6379) + ], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + } +} +``` + +!!! note: + It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible + for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via + `SENTINELS`/`SENTINEL_SERVICE`. + + --- ## SECRET_KEY diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 4c490eebf..53b2215b3 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/ The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. -If there's a strong case for introducing a new depdency, it must meet the following criteria: +If there's a strong case for introducing a new dependency, it must meet the following criteria: * Its complete source code must be published and freely accessible without registration. * Its license must be conducive to inclusion in an open source project. @@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. +* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. + * No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely. * Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. -* Every model should have a docstring. Every custom method should include an expalantion of its function. +* Every model should have a docstring. Every custom method should include an explanation of its function. * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. + +## Branding + +* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation. + +* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. diff --git a/docs/installation/index.md b/docs/installation/index.md index 4962eb7d0..59631bf7a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) 2. [NetBox components](2-netbox.md) -3. [HTTP dameon](3-http-daemon.md) +3. [HTTP daemon](3-http-daemon.md) 4. [LDAP authentication](4-ldap.md) (optional) # Upgrading diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6a2c0188f..e5cf93a28 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui ```no-highlight # sudo systemctl restart netbox -# sudo systemctl restart netbox-rqworker +# sudo systemctl restart netbox-rq ``` !!! note diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index b4661e21b..04b3972ca 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,38 @@ +# v2.7.5 (2020-02-13) + +**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox. + +## Enhancements + +* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable +* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components +* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel +* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines +* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views +* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components +* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views +* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components + +## Bug Fixes + +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices +* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens +* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests +* [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional +* [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view +* [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form +* [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list +* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type +* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit +* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams +* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption +* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts +* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration + +--- + # v2.7.4 (2020-02-04) ## Enhancements diff --git a/mkdocs.yml b/mkdocs.yml index 86cf9fead..4ba91dfe5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,7 +41,6 @@ pages: - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' - - Topology Maps: 'additional-features/topology-maps.md' - Webhooks: 'additional-features/webhooks.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b9d1b439b..cd3015d0a 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -15,15 +15,15 @@ router = routers.DefaultRouter() router.APIRootView = CircuitsRootView # Field choices -router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') # Providers -router.register(r'providers', views.ProviderViewSet) +router.register('providers', views.ProviderViewSet) # Circuits -router.register(r'circuit-types', views.CircuitTypeViewSet) -router.register(r'circuits', views.CircuitViewSet) -router.register(r'circuit-terminations', views.CircuitTerminationViewSet) +router.register('circuit-types', views.CircuitTypeViewSet) +router.register('circuits', views.CircuitViewSet) +router.register('circuit-terminations', views.CircuitTerminationViewSet) app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index caf8d9d36..0b0378a7a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -107,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -119,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -164,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm): # class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + widget=APISelect( + api_url="/api/circuits/providers/" + ) + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all(), + widget=APISelect( + api_url="/api/circuits/circuit-types/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -180,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'commit_rate': "Committed rate", } widgets = { - 'provider': APISelect( - api_url="/api/circuits/providers/" - ), - 'type': APISelect( - api_url="/api/circuits/circuit-types/" - ), 'status': StaticSelect2(), 'install_date': DatePicker(), } @@ -235,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False, widget=APISelect( api_url="/api/circuits/circuit-types/" ) ) - provider = forms.ModelChoiceField( + provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, widget=APISelect( @@ -255,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit initial='', widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -290,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label='Search' ) - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/circuit-types/", value_field="slug", ) ) - provider = FilterChoiceField( + provider = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/providers/", value_field="slug", @@ -311,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -323,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index d2cb8e5ab..9cc7af6ae 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -2,10 +2,10 @@ import datetime from circuits.choices import * from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class ProviderTestCase(StandardTestCases.Views): +class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Provider @classmethod @@ -46,14 +46,9 @@ class ProviderTestCase(StandardTestCases.Views): } -class CircuitTypeTestCase(StandardTestCases.Views): +class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = CircuitType - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -77,7 +72,7 @@ class CircuitTypeTestCase(StandardTestCases.Views): ) -class CircuitTestCase(StandardTestCases.Views): +class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit @classmethod diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index c142a831a..72d9720df 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -9,42 +9,42 @@ app_name = 'circuits' urlpatterns = [ # Providers - path(r'providers/', views.ProviderListView.as_view(), name='provider_list'), - path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), - path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), - path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), - path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path(r'providers//', views.ProviderView.as_view(), name='provider'), - path(r'providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path(r'providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path(r'providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers/', views.ProviderListView.as_view(), name='provider_list'), + path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), + path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), + path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), + path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types - path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), - path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), - path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), - path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path(r'circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path(r'circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), + path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits - path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'), - path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), - path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), - path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), - path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - path(r'circuits//', views.CircuitView.as_view(), name='circuit'), - path(r'circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), - path(r'circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), - path(r'circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path(r'circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), + path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), + path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), + path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), + path('circuits//', views.CircuitView.as_view(), name='circuit'), + path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), + path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), + path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations - path(r'circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), - path(r'circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), - path(r'circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path(r'circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), - path(r'circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + path('circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f0382a3f5..234a9fb1c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) @@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) @@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = PowerPortTemplateSerializer( @@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) class Meta: @@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = NestedPowerPortSerializer( @@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) cable = NestedCableSerializer( read_only=True @@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) - length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fd55d9b05..5a915becc 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -15,65 +15,65 @@ router = routers.DefaultRouter() router.APIRootView = DCIMRootView # Field choices -router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') # Sites -router.register(r'regions', views.RegionViewSet) -router.register(r'sites', views.SiteViewSet) +router.register('regions', views.RegionViewSet) +router.register('sites', views.SiteViewSet) # Racks -router.register(r'rack-groups', views.RackGroupViewSet) -router.register(r'rack-roles', views.RackRoleViewSet) -router.register(r'racks', views.RackViewSet) -router.register(r'rack-reservations', views.RackReservationViewSet) +router.register('rack-groups', views.RackGroupViewSet) +router.register('rack-roles', views.RackRoleViewSet) +router.register('racks', views.RackViewSet) +router.register('rack-reservations', views.RackReservationViewSet) # Device types -router.register(r'manufacturers', views.ManufacturerViewSet) -router.register(r'device-types', views.DeviceTypeViewSet) +router.register('manufacturers', views.ManufacturerViewSet) +router.register('device-types', views.DeviceTypeViewSet) # Device type components -router.register(r'console-port-templates', views.ConsolePortTemplateViewSet) -router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet) -router.register(r'power-port-templates', views.PowerPortTemplateViewSet) -router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) -router.register(r'interface-templates', views.InterfaceTemplateViewSet) -router.register(r'front-port-templates', views.FrontPortTemplateViewSet) -router.register(r'rear-port-templates', views.RearPortTemplateViewSet) -router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('console-port-templates', views.ConsolePortTemplateViewSet) +router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet) +router.register('power-port-templates', views.PowerPortTemplateViewSet) +router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) +router.register('interface-templates', views.InterfaceTemplateViewSet) +router.register('front-port-templates', views.FrontPortTemplateViewSet) +router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('device-bay-templates', views.DeviceBayTemplateViewSet) # Devices -router.register(r'device-roles', views.DeviceRoleViewSet) -router.register(r'platforms', views.PlatformViewSet) -router.register(r'devices', views.DeviceViewSet) +router.register('device-roles', views.DeviceRoleViewSet) +router.register('platforms', views.PlatformViewSet) +router.register('devices', views.DeviceViewSet) # Device components -router.register(r'console-ports', views.ConsolePortViewSet) -router.register(r'console-server-ports', views.ConsoleServerPortViewSet) -router.register(r'power-ports', views.PowerPortViewSet) -router.register(r'power-outlets', views.PowerOutletViewSet) -router.register(r'interfaces', views.InterfaceViewSet) -router.register(r'front-ports', views.FrontPortViewSet) -router.register(r'rear-ports', views.RearPortViewSet) -router.register(r'device-bays', views.DeviceBayViewSet) -router.register(r'inventory-items', views.InventoryItemViewSet) +router.register('console-ports', views.ConsolePortViewSet) +router.register('console-server-ports', views.ConsoleServerPortViewSet) +router.register('power-ports', views.PowerPortViewSet) +router.register('power-outlets', views.PowerOutletViewSet) +router.register('interfaces', views.InterfaceViewSet) +router.register('front-ports', views.FrontPortViewSet) +router.register('rear-ports', views.RearPortViewSet) +router.register('device-bays', views.DeviceBayViewSet) +router.register('inventory-items', views.InventoryItemViewSet) # Connections -router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') -router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections') -router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') +router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') +router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections') +router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') # Cables -router.register(r'cables', views.CableViewSet) +router.register('cables', views.CableViewSet) # Virtual chassis -router.register(r'virtual-chassis', views.VirtualChassisViewSet) +router.register('virtual-chassis', views.VirtualChassisViewSet) # Power -router.register(r'power-panels', views.PowerPanelViewSet) -router.register(r'power-feeds', views.PowerFeedViewSet) +router.register('power-panels', views.PowerPanelViewSet) +router.register('power-feeds', views.PowerFeedViewSet) # Miscellaneous -router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') +router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device') app_name = 'dcim-api' urlpatterns = router.urls diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0e05867e4..13a5052e4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -9,6 +9,8 @@ from .choices import InterfaceTypeChoices RACK_U_HEIGHT_DEFAULT = 42 +RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 + RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 723491610..8f035ccbb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,10 +22,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, - SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -42,7 +41,7 @@ DEVICE_BY_PK_RE = r'{\d+\}' INTERFACE_MODE_HELP_TEXT = """ Access: One untagged VLAN
Tagged: One untagged VLAN and/or one or more tagged VLANs
-Tagged All: Implies all VLANs are available (w/optional untagged VLAN) +Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ @@ -67,7 +66,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -79,12 +78,24 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", - value_field="slug" + value_field="slug", + filter_for={ + 'device_id': 'site', + } + ) + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', ) ) @@ -171,18 +182,18 @@ class MACAddressField(forms.Field): # class RegionForm(BootstrapMixin, forms.ModelForm): + parent = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2() + ) slug = SlugField() class Meta: model = Region - fields = [ + fields = ( 'parent', 'name', 'slug', - ] - widgets = { - 'parent': APISelect( - api_url="/api/dcim/regions/" - ) - } + ) class RegionCSVForm(forms.ModelForm): @@ -221,9 +232,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) slug = SlugField() comments = CommentField() @@ -314,11 +323,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -359,7 +366,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -376,18 +383,20 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: model = RackGroup - fields = [ + fields = ( 'site', 'name', 'slug', - ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } + ) class RackGroupCSVForm(forms.ModelForm): @@ -410,7 +419,7 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -422,9 +431,10 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -463,16 +473,29 @@ class RackRoleCSVForm(forms.ModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - group = ChainedModelChoiceField( + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id', + } + ) + ) + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', ) ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-roles/', + ) + ) comments = CommentField() tags = TagField( required=False @@ -491,16 +514,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'u_height': "Height in rack units", } widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'group': 'site_id', - } - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/dcim/rack-roles/" - ), 'type': StaticSelect2(), 'width': StaticSelect2(), 'outer_unit': StaticSelect2(), @@ -600,7 +614,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -610,14 +624,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor } ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/rack-groups", ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -630,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False, widget=APISelect( @@ -696,7 +710,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -708,9 +722,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -719,12 +734,12 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related( 'site' ), + required=False, label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True @@ -735,10 +750,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", @@ -754,13 +769,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = ChainedModelChoiceField( + id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label='Rack', - chains=( - ('site', 'site'), - ('group_id', 'group_id'), - ), required=False, widget=APISelectMultiple( api_url='/api/dcim/racks/', @@ -831,7 +842,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -853,18 +864,19 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), + required=False, label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -902,6 +914,12 @@ class ManufacturerCSVForm(forms.ModelForm): # class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( slug_source='model' ) @@ -917,9 +935,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'tags', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'subdevice_role': StaticSelect2() } @@ -942,11 +957,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput() ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/manufactureres" + api_url="/api/dcim/manufacturers" ) ) u_height = forms.IntegerField( @@ -969,9 +984,10 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -1043,16 +1059,37 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(ComponentForm): +class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) type = forms.ChoiceField( - choices=ConsolePortTypeChoices, + choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) +class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type',) + + class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1065,7 +1102,13 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(ComponentForm): +class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1075,6 +1118,21 @@ class ConsoleServerPortTemplateCreateForm(ComponentForm): ) +class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type',) + + class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1087,7 +1145,13 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(ComponentForm): +class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1107,6 +1171,31 @@ class PowerPortTemplateCreateForm(ComponentForm): ) +class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect2() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum power draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated power draw (watts)" + ) + + class Meta: + nullable_fields = ('type', 'maximum_draw', 'allocated_draw') + + class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1129,7 +1218,13 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletTemplateCreateForm(ComponentForm): +class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1148,13 +1243,35 @@ class PowerOutletTemplateCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit power_port choices to current DeviceType - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.parent + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') ) + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=device_type + ) + + +class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutletTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerOutletTypeChoices), + required=False, + widget=StaticSelect2() + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(PowerOutletFeedLegChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = ('type', 'feed_leg') class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1170,7 +1287,13 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(ComponentForm): +class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1227,7 +1350,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): ) -class FrontPortTemplateCreateForm(ComponentForm): +class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1242,18 +1371,21 @@ class FrontPortTemplateCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. occupied_port_positions = [ (front_port.rear_port_id, front_port.rear_port_position) - for front_port in self.parent.frontport_templates.all() + for front_port in device_type.frontport_templates.all() ] # Populate rear port choices choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=self.parent) + rear_ports = RearPortTemplate.objects.filter(device_type=device_type) for rear_port in rear_ports: for i in range(1, rear_port.positions + 1): if (rear_port.pk, i) not in occupied_port_positions: @@ -1284,6 +1416,21 @@ class FrontPortTemplateCreateForm(ComponentForm): } +class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = () + + class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1297,7 +1444,13 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class RearPortTemplateCreateForm(ComponentForm): +class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -1313,6 +1466,21 @@ class RearPortTemplateCreateForm(ComponentForm): ) +class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = () + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1325,12 +1493,29 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(ComponentForm): +class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-types/' + ) + ) name_pattern = ExpandableNameField( label='Name' ) +# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet +# class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): +# pk = forms.ModelMultipleChoiceField( +# queryset=FrontPortTemplate.objects.all(), +# widget=forms.MultipleHiddenInput() +# ) +# +# class Meta: +# nullable_fields = () + + # # Component template import forms # @@ -1480,6 +1665,13 @@ class DeviceRoleCSVForm(forms.ModelForm): # class PlatformForm(BootstrapMixin, forms.ModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( max_length=64 ) @@ -1490,9 +1682,6 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'napalm_args': SmallTextarea(), } @@ -1522,7 +1711,7 @@ class PlatformCSVForm(forms.ModelForm): # class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( api_url="/api/dcim/sites/", @@ -1531,11 +1720,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -1551,7 +1737,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): disabled_indicator='device' ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -1562,18 +1748,30 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - device_type = ChainedModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - chains=( - ('manufacturer', 'manufacturer'), - ), - label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', display_field='model' ) ) - cluster_group = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-roles/' + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/platforms/", + additional_query_params={ + "manufacturer_id": "null" + } + ) + ) + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -1586,11 +1784,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), required=False, widget=APISelect( api_url='/api/virtualization/clusters/', @@ -1622,16 +1817,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'position': 'face' } ), - 'device_role': APISelect( - api_url='/api/dcim/device-roles/' - ), 'status': StaticSelect2(), - 'platform': APISelect( - api_url="/api/dcim/platforms/", - additional_query_params={ - "manufacturer_id": "null" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), } @@ -1905,31 +2091,29 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() ) - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Type', widget=APISelect( api_url="/api/dcim/device-types/", display_field='display_name' ) ) - device_role = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, - label='Role', widget=APISelect( api_url="/api/dcim/device-roles/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url="/api/tenancy/tenants/" ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -1939,7 +2123,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), required=False, - initial='', widget=StaticSelect2() ) serial = forms.CharField( @@ -1964,7 +2147,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1976,9 +2159,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1988,10 +2172,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.prefetch_related( - 'site' - ), + rack_group_id = DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -2000,25 +2183,27 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - manufacturer_id = FilterChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), + required=False, label='Manufacturer', widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", @@ -2027,20 +2212,19 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - device_type_id = FilterChoiceField( - queryset=DeviceType.objects.prefetch_related( - 'manufacturer' - ), + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, label='Model', widget=APISelectMultiple( api_url="/api/dcim/device-types/", display_field="model", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -2179,7 +2363,13 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(ComponentForm): +class ConsolePortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2197,6 +2387,27 @@ class ConsolePortCreateForm(ComponentForm): ) +class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class ConsolePortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2237,7 +2448,13 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(ComponentForm): +class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2330,7 +2547,13 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(ComponentForm): +class PowerPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2358,6 +2581,37 @@ class PowerPortCreateForm(ComponentForm): ) +class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect2() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum draw in watts" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated draw in watts" + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class PowerPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2411,7 +2665,13 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletCreateForm(ComponentForm): +class PowerOutletCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2437,11 +2697,13 @@ class PowerOutletCreateForm(ComponentForm): ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit power_port choices to those on the parent device - self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) + # Limit power_port queryset to PowerPorts which belong to the parent Device + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) class PowerOutletCSVForm(forms.ModelForm): @@ -2499,8 +2761,13 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput() ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=forms.HiddenInput() + ) type = forms.ChoiceField( - choices=PowerOutletTypeChoices, + choices=add_blank_choice(PowerOutletTypeChoices), required=False ) feed_leg = forms.ChoiceField( @@ -2525,7 +2792,12 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): super().__init__(*args, **kwargs) # Limit power_port queryset to PowerPorts which belong to the parent Device - self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent_obj) + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True class PowerOutletBulkRenameForm(BulkRenameForm): @@ -2553,25 +2825,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm): class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + label='Untagged VLAN', widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, + label='Tagged VLANs', widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tags = TagField( required=False ) @@ -2612,10 +2885,12 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): ) -class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - widget=forms.HiddenInput() +class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) ) name_pattern = ExpandableNameField( label='Name' @@ -2625,7 +2900,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): widget=StaticSelect2(), ) enabled = forms.BooleanField( - required=False + required=False, + initial=True ) lag = forms.ModelChoiceField( queryset=Interface.objects.all(), @@ -2660,7 +2936,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): tags = TagField( required=False ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -2669,7 +2945,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -2680,21 +2956,16 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): ) def __init__(self, *args, **kwargs): - - # Set interfaces enabled by default - kwargs['initial'] = kwargs.get('initial', {}).copy() - kwargs['initial'].update({'enabled': True}) - super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or its VC master) - if self.parent is not None: - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.parent, self.parent.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) - else: - self.fields['lag'].queryset = Interface.objects.none() + # Limit LAG choices to interfaces which belong to the parent device (or VC master) + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + type=InterfaceTypeChoices.TYPE_LAG + ) class InterfaceCSVForm(forms.ModelForm): @@ -2769,6 +3040,11 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=forms.HiddenInput() + ) type = forms.ChoiceField( choices=add_blank_choice(InterfaceTypeChoices), required=False, @@ -2808,7 +3084,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -2817,7 +3093,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -2836,14 +3112,15 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): super().__init__(*args, **kwargs) # Limit LAG choices to interfaces which belong to the parent device (or VC master) - device = self.parent_obj - if device is not None: + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG ) else: - self.fields['lag'].choices = [] + self.fields['lag'].choices = () + self.fields['lag'].widget.attrs['disabled'] = True def clean(self): @@ -2908,7 +3185,13 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic -class FrontPortCreateForm(ComponentForm): +class FrontPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -2928,15 +3211,20 @@ class FrontPortCreateForm(ComponentForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. occupied_port_positions = [ (front_port.rear_port_id, front_port.rear_port_position) - for front_port in self.parent.frontports.all() + for front_port in device.frontports.all() ] # Populate rear port choices choices = [] - rear_ports = RearPort.objects.filter(device=self.parent) + rear_ports = RearPort.objects.filter(device=device) for rear_port in rear_ports: for i in range(1, rear_port.positions + 1): if (rear_port.pk, i) not in occupied_port_positions: @@ -3075,7 +3363,13 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): } -class RearPortCreateForm(ComponentForm): +class RearPortCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -3151,11 +3445,11 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3167,11 +3461,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_rack = ChainedModelChoiceField( + termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'termination_b_site'), - ), label='Rack', required=False, widget=APISelect( @@ -3184,12 +3475,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_device = ChainedModelChoiceField( + termination_b_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack', 'termination_b_rack'), - ), label='Device', required=False, widget=APISelect( @@ -3287,8 +3574,8 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - termination_b_provider = forms.ModelChoiceField( +class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): + termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', required=False, @@ -3299,7 +3586,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3310,11 +3597,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_circuit = ChainedModelChoiceField( + termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), - chains=( - ('provider', 'termination_b_provider'), - ), label='Circuit', widget=APISelect( api_url='/api/circuits/circuits/', @@ -3329,7 +3613,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f widget=APISelect( api_url='/api/circuits/circuit-terminations/', disabled_indicator='cable', - display_field='term_side' + display_field='term_side', + full=True ) ) @@ -3341,8 +3626,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f ] -class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - termination_b_site = forms.ModelChoiceField( +class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3355,12 +3640,9 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_rackgroup = ChainedModelChoiceField( + termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), label='Rack Group', - chains=( - ('site', 'termination_b_site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -3370,12 +3652,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_powerpanel = ChainedModelChoiceField( + termination_b_powerpanel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack_group', 'termination_b_rackgroup'), - ), label='Power Panel', required=False, widget=APISelect( @@ -3599,20 +3877,23 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'rack_id': 'site', + 'device_id': 'site', } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field='slug', @@ -3621,13 +3902,16 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, + filter_for={ + 'device_id': 'rack_id', + } ) ) type = forms.MultipleChoiceField( @@ -3645,7 +3929,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3679,7 +3963,13 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(ComponentForm): +class DeviceBayCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) name_pattern = ExpandableNameField( label='Name' ) @@ -3771,15 +4061,19 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3790,15 +4084,19 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3809,15 +4107,19 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3832,6 +4134,19 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/" + ) + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) tags = TagField( required=False ) @@ -3841,14 +4156,42 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] - widgets = { - 'device': APISelect( - api_url="/api/dcim/devices/" - ), - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ) - } + + +class InventoryItemCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/", + ) + ) + name_pattern = ExpandableNameField( + label='Name' + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + serial = forms.CharField( + max_length=50, + required=False, + ) + asset_tag = forms.CharField( + max_length=50, + required=False, + ) + description = forms.CharField( + max_length=100, + required=False + ) class InventoryItemCSVForm(forms.ModelForm): @@ -3880,14 +4223,14 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/devices/" ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -3916,7 +4259,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -3928,9 +4271,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -3939,7 +4283,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -3947,9 +4291,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): api_url='/api/dcim/devices/', ) ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -4044,10 +4389,9 @@ class DeviceVCMembershipForm(forms.ModelForm): return vc_position -class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( +class VCMemberSelectForm(BootstrapMixin, forms.Form): + site = DynamicModelChoiceField( queryset=Site.objects.all(), - label='Site', required=False, widget=APISelect( api_url="/api/dcim/sites/", @@ -4057,12 +4401,8 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -4074,15 +4414,10 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - device = ChainedModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.filter( virtual_chassis__isnull=True ), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', widget=APISelect( api_url='/api/dcim/devices/', display_field='display_name', @@ -4105,7 +4440,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4117,18 +4452,19 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4138,10 +4474,10 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", @@ -4156,11 +4492,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): - rack_group = ChainedModelChoiceField( + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ) + ) + rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -4172,14 +4515,6 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'rack_group', 'name', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'rack_group': 'site_id', - } - ), - } class PowerPanelCSVForm(forms.ModelForm): @@ -4223,7 +4558,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4235,9 +4570,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4246,10 +4582,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - rack_group_id = FilterChoiceField( + rack_group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), + required=False, label='Rack group (ID)', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -4262,7 +4598,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -4273,6 +4609,19 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): } ) ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + widget=APISelect( + api_url="/api/dcim/power-panels/" + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -4285,12 +4634,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'max_utilization', 'comments', 'tags', ] widgets = { - 'power_panel': APISelect( - api_url="/api/dcim/power-panels/" - ), - 'rack': APISelect( - api_url="/api/dcim/racks/" - ), 'status': StaticSelect2(), 'type': StaticSelect2(), 'supply': StaticSelect2(), @@ -4389,7 +4732,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - power_panel = forms.ModelChoiceField( + power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( @@ -4399,7 +4742,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } ) ) - rack = forms.ModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, widget=APISelect( @@ -4456,7 +4799,7 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4468,9 +4811,10 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4480,19 +4824,19 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - power_panel_id = FilterChoiceField( + power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), + required=False, label='Power panel', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index e1124b84e..502719646 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -1,18 +1,7 @@ from django.db.models import Manager, QuerySet -from django.db.models.expressions import RawSQL from .constants import NONCONNECTABLE_IFACE_TYPES -# Regular expressions for parsing Interface names -TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')" -SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)" -SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)" -POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)" -SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)" -ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)" -CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" -VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" - class InterfaceQuerySet(QuerySet): @@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet): class InterfaceManager(Manager): def get_queryset(self): - """ - Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field - is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel, - and virtual circuit: - - {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are coalesced to zero or null. For example, an interface named - GigabitEthernet1/2/3 would be parsed as follows: - - type = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = None - id = None - channel = 0 - vc = 0 - - The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not - match any of the prescribed fields. - - The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device - components. - """ - - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = [ - '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk' - - ] - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering) + return InterfaceQuerySet(self.model, using=self._db) diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py new file mode 100644 index 000000000..017241c8b --- /dev/null +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -0,0 +1,147 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_consoleports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePort')) + + +def naturalize_consoleserverports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPort')) + + +def naturalize_powerports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPort')) + + +def naturalize_poweroutlets(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerOutlet')) + + +def naturalize_frontports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPort')) + + +def naturalize_rearports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPort')) + + +def naturalize_devicebays(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBay')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0092_fix_rack_outer_unit'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebay', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='frontport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='inventoryitem', + options={'ordering': ('device__id', 'parent__id', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlet', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='powerport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='rearport', + options={'ordering': ('device', '_name')}, + ), + migrations.AddField( + model_name='consoleport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebay', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='inventoryitem', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlet', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_consoleserverports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_powerports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_poweroutlets, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_frontports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_rearports, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devicebays, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py new file mode 100644 index 000000000..fc39f76b2 --- /dev/null +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -0,0 +1,138 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_consoleporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePortTemplate')) + + +def naturalize_consoleserverporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate')) + + +def naturalize_powerporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPortTemplate')) + + +def naturalize_poweroutlettemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerOutletTemplate')) + + +def naturalize_frontporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPortTemplate')) + + +def naturalize_rearporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPortTemplate')) + + +def naturalize_devicebaytemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBayTemplate')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0093_device_component_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebaytemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='consoleporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_consoleserverporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_powerporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_poweroutlettemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_frontporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_rearporttemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devicebaytemplates, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py new file mode 100644 index 000000000..9cef0a581 --- /dev/null +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -0,0 +1,70 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_sites(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Site')) + + +def naturalize_racks(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Rack')) + + +def naturalize_devices(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Device')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0094_device_component_template_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'group', '_name', 'pk')}, + ), + migrations.AlterModelOptions( + name='site', + options={'ordering': ('_name',)}, + ), + migrations.AddField( + model_name='device', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), + ), + migrations.AddField( + model_name='rack', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='site', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_sites, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_racks, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_devices, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py new file mode 100644 index 000000000..284066462 --- /dev/null +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -0,0 +1,53 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name)) + + +def naturalize_interfacetemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'InterfaceTemplate')) + + +def naturalize_interfaces(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Interface')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0095_primary_model_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='interface', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='interface', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.AddField( + model_name='interfacetemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.RunPython( + code=naturalize_interfacetemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_interfaces, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 2a4988ba4..f291fc825 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -22,8 +22,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem -from utilities.fields import ColorField -from utilities.managers import NaturalOrderingManager +from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import foreground_color, to_meters from .device_component_templates import ( @@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel): max_length=50, unique=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) slug = models.SlugField( unique=True ) @@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): } class Meta: - ordering = ['name'] + ordering = ('_name',) def __str__(self): return self.name @@ -387,6 +389,10 @@ class RackElevationHelperMixin: @staticmethod def _draw_device_front(drawing, device, start, end, text): + name = str(device) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + color = device.device_role.color link = drawing.add( drawing.a( @@ -401,7 +407,7 @@ class RackElevationHelperMixin: )) link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(device), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text, fill=hex_color)) @staticmethod def _draw_device_rear(drawing, device, start, end, text): @@ -431,11 +437,19 @@ class RackElevationHelperMixin: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): + def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width): - drawing = self._setup_drawing(unit_width, unit_height * self.u_height) + drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height) unit_cursor = 0 + for ru in range(0, self.u_height): + start_y = ru * unit_height + position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2) + unit = ru + 1 if self.desc_units else self.u_height - ru + drawing.add( + drawing.text(str(unit), position_coordinates, class_="unit") + ) + for unit in elevation: # Loop through all units in the elevation @@ -445,9 +459,9 @@ class RackElevationHelperMixin: # Setup drawing coordinates start_y = unit_cursor * unit_height end_y = unit_height * height - start_cordinates = (0, start_y) - end_cordinates = (unit_width, end_y) - text_cordinates = (unit_width / 2, start_y + end_y / 2) + start_cordinates = (legend_width, start_y) + end_cordinates = (legend_width + unit_width, end_y) + text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2) # Draw the device if device and device.face == face: @@ -469,7 +483,7 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing @@ -492,7 +506,8 @@ class RackElevationHelperMixin: self, face=DeviceFaceChoices.FACE_FRONT, unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT ): """ Return an SVG of the rack elevation @@ -505,7 +520,7 @@ class RackElevationHelperMixin: elevation = self.merge_elevations(face) reserved_units = self.get_reserved_units() - return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width) class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): @@ -516,6 +531,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) facility_id = models.CharField( max_length=50, blank=True, @@ -612,8 +632,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -634,12 +652,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): } class Meta: - ordering = ('site', 'group', 'name', 'pk') # (site, group, name) may be non-unique - unique_together = [ + ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique + unique_together = ( # Name and facility_id must be unique *only* within a RackGroup - ['group', 'name'], - ['group', 'facility_id'], - ] + ('group', 'name'), + ('group', 'facility_id'), + ) def __str__(self): return self.display_name or super().__str__() @@ -1313,6 +1331,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): blank=True, null=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True, + null=True + ) serial = models.CharField( max_length=50, blank=True, @@ -1407,8 +1431,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -1430,12 +1452,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): } class Meta: - ordering = ('name', 'pk') # Name may be NULL - unique_together = [ - ['site', 'tenant', 'name'], # See validate_unique below - ['rack', 'position', 'face'], - ['virtual_chassis', 'vc_position'], - ] + ordering = ('_name', 'pk') # Name may be null + unique_together = ( + ('site', 'tenant', 'name'), # See validate_unique below + ('rack', 'position', 'face'), + ('virtual_chassis', 'vc_position'), + ) permissions = ( ('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'), diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 2aa46d0ea..faa42b035 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,9 +4,9 @@ from django.db import models from dcim.choices import * from dcim.constants import * -from dcim.managers import InterfaceManager from extras.models import ObjectChange -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, @@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel): help_text="Allocated power draw (watts)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -159,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel): return PowerPort( device=device, name=self.name, + type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw ) @@ -176,6 +186,11 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -195,11 +210,9 @@ class PowerOutletTemplate(ComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -220,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel): return PowerOutlet( device=device, name=self.name, + type=self.type, power_port=power_port, feed_leg=self.feed_leg ) @@ -237,6 +251,12 @@ class InterfaceTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=InterfaceTypeChoices @@ -246,11 +266,9 @@ class InterfaceTemplate(ComponentTemplateModel): verbose_name='Management only' ) - objects = InterfaceManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -276,6 +294,11 @@ class FrontPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -290,14 +313,12 @@ class FrontPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = [ - ['device_type', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -344,6 +365,11 @@ class RearPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -353,11 +379,9 @@ class RearPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -383,12 +407,15 @@ class DeviceBayTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) - - objects = NaturalOrderingManager() + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e37569f79..a41eda576 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,9 +10,9 @@ from dcim.choices import * from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField -from dcim.managers import InterfaceManager from extras.models import ObjectChange, TaggedItem -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -515,6 +529,12 @@ class Interface(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) _connected_interface = models.OneToOneField( to='self', on_delete=models.SET_NULL, @@ -583,8 +603,6 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) - - objects = InterfaceManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -593,8 +611,9 @@ class Interface(CableTermination, ComponentModel): ] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + # TODO: ordering and unique_together should include virtual_machine + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -761,6 +780,11 @@ class FrontPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -774,20 +798,17 @@ class FrontPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = [ - ['device', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device', '_name') + unique_together = ( + ('device', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -831,6 +852,11 @@ class RearPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -839,17 +865,14 @@ class RearPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -881,6 +904,11 @@ class DeviceBay(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) installed_device = models.OneToOneField( to='dcim.Device', on_delete=models.SET_NULL, @@ -888,15 +916,13 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'installed_device', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return '{} - {}'.format(self.device.name, self.name) @@ -960,6 +986,11 @@ class InventoryItem(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -997,14 +1028,14 @@ class InventoryItem(ComponentModel): ] class Meta: - ordering = ['device__id', 'parent__id', 'name'] - unique_together = ['device', 'parent', 'name'] + ordering = ('device__id', 'parent__id', '_name') + unique_together = ('device', 'parent', 'name') def __str__(self): return self.name def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk}) def to_csv(self): return ( diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 7e1da41d4..1f67b93f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -200,6 +200,11 @@ def get_component_template_actions(model_name): {{% endif %}} + {{% if perms.dcim.delete_{model_name} %}} + + + + {{% endif %}} """.format(model_name=model_name).strip() @@ -229,7 +234,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn(order_by=('_name',)) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -291,7 +296,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn(order_by=('_name',)) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -409,6 +414,7 @@ class DeviceTypeTable(BaseTable): class ConsolePortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -432,6 +438,7 @@ class ConsolePortImportTable(BaseTable): class ConsoleServerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleserverporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -440,7 +447,7 @@ class ConsoleServerPortTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = ConsoleServerPortTemplate - fields = ('pk', 'name', 'actions') + fields = ('pk', 'name', 'type', 'actions') empty_text = "None" @@ -455,6 +462,7 @@ class ConsoleServerPortImportTable(BaseTable): class PowerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('powerporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -478,6 +486,7 @@ class PowerPortImportTable(BaseTable): class PowerOutletTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('poweroutlettemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -526,6 +535,7 @@ class InterfaceImportTable(BaseTable): class FrontPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) rear_port_position = tables.Column( verbose_name='Position' ) @@ -552,6 +562,7 @@ class FrontPortImportTable(BaseTable): class RearPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('rearporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -575,6 +586,7 @@ class RearPortImportTable(BaseTable): class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('devicebaytemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -654,7 +666,7 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() name = tables.TemplateColumn( - order_by=('_nat1', '_nat2', '_nat3'), + order_by=('_name',), template_code=DEVICE_LINK ) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') @@ -704,6 +716,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() class Meta(BaseTable.Meta): @@ -713,6 +726,7 @@ class DeviceComponentDetailTable(BaseTable): class ConsolePortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsolePort @@ -727,6 +741,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable): class ConsoleServerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsoleServerPort @@ -741,6 +756,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable): class PowerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerPort @@ -755,6 +771,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable): class PowerOutletTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerOutlet @@ -777,6 +794,7 @@ class InterfaceTable(BaseTable): class InterfaceDetailTable(DeviceComponentDetailTable): parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) + name = tables.LinkColumn() class Meta(InterfaceTable.Meta): order_by = ('parent', 'name') @@ -785,6 +803,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable): class FrontPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = FrontPort @@ -800,6 +819,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable): class RearPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = RearPort @@ -815,6 +835,7 @@ class RearPortDetailTable(DeviceComponentDetailTable): class DeviceBayTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = DeviceBay diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index aa880b67d..704dedb40 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases def create_test_device(name): @@ -27,14 +27,9 @@ def create_test_device(name): return device -class RegionTestCase(StandardTestCases.Views): +class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -61,7 +56,7 @@ class RegionTestCase(StandardTestCases.Views): ) -class SiteTestCase(StandardTestCases.Views): +class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Site @classmethod @@ -118,14 +113,9 @@ class SiteTestCase(StandardTestCases.Views): } -class RackGroupTestCase(StandardTestCases.Views): +class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -152,14 +142,9 @@ class RackGroupTestCase(StandardTestCases.Views): ) -class RackRoleTestCase(StandardTestCases.Views): +class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -184,7 +169,7 @@ class RackRoleTestCase(StandardTestCases.Views): ) -class RackReservationTestCase(StandardTestCases.Views): +class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = RackReservation # Disable inapplicable tests @@ -226,7 +211,7 @@ class RackReservationTestCase(StandardTestCases.Views): } -class RackTestCase(StandardTestCases.Views): +class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Rack @classmethod @@ -302,14 +287,9 @@ class RackTestCase(StandardTestCases.Views): } -class ManufacturerTestCase(StandardTestCases.Views): +class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Manufacturer - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -332,7 +312,7 @@ class ManufacturerTestCase(StandardTestCases.Views): ) -class DeviceTypeTestCase(StandardTestCases.Views): +class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = DeviceType @classmethod @@ -524,14 +504,318 @@ device-bays: self.assertEqual(data[0]['model'], 'Device Type 1') -class DeviceRoleTestCase(StandardTestCases.Views): - model = DeviceRole +# +# DeviceType components +# - # Disable inapplicable tests - test_get_object = None - test_delete_object = None +class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ConsolePortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + ConsolePortTemplate.objects.bulk_create(( + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'), + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'), + ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Console Port Template X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Console Port Template [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + +class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ConsoleServerPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + ConsoleServerPortTemplate.objects.bulk_create(( + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'), + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'), + ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Console Server Port Template X', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Console Server Port Template [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + + +class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = PowerPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + PowerPortTemplate.objects.bulk_create(( + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'), + PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Power Port Template X', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Power Port Template [4-6]', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + cls.bulk_edit_data = { + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + } + + +class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = PowerOutletTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + + PowerOutletTemplate.objects.bulk_create(( + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'), + PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'), + )) + + powerports = ( + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + ) + PowerPortTemplate.objects.bulk_create(powerports) + + cls.form_data = { + 'device_type': devicetype.pk, + 'name': 'Power Outlet Template X', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[0].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + cls.bulk_create_data = { + 'device_type': devicetype.pk, + 'name_pattern': 'Power Outlet Template [4-6]', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[0].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + cls.bulk_edit_data = { + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + } + + +class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = InterfaceTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + InterfaceTemplate.objects.bulk_create(( + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'), + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'), + InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Interface Template X', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Interface Template [4-6]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + cls.bulk_edit_data = { + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mgmt_only': True, + } + + +class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = FrontPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + + rearports = ( + RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 4'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'), + ) + RearPortTemplate.objects.bulk_create(rearports) + + FrontPortTemplate.objects.bulk_create(( + FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1), + )) + + cls.form_data = { + 'device_type': devicetype.pk, + 'name': 'Front Port X', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rearports[3].pk, + 'rear_port_position': 1, + } + + cls.bulk_create_data = { + 'device_type': devicetype.pk, + 'name_pattern': 'Front Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port_set': [ + '{}:1'.format(rp.pk) for rp in rearports[3:6] + ], + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + } + + +class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = RearPortTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + RearPortTemplate.objects.bulk_create(( + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Rear Port Template X', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 2, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Rear Port Template [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 2, + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + } + + +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = DeviceBayTemplate + + # Disable inapplicable views test_bulk_edit_objects = None + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + DeviceBayTemplate.objects.bulk_create(( + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'), + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'), + DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Device Bay Template X', + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Device Bay Template [4-6]', + } + + +class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = DeviceRole + @classmethod def setUpTestData(cls): @@ -557,14 +841,9 @@ class DeviceRoleTestCase(StandardTestCases.Views): ) -class PlatformTestCase(StandardTestCases.Views): +class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Platform - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -592,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views): ) -class DeviceTestCase(StandardTestCases.Views): +class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device @classmethod @@ -677,17 +956,9 @@ class DeviceTestCase(StandardTestCases.Views): } -class ConsolePortTestCase(StandardTestCases.Views): +class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort - # Disable inapplicable views - test_get_object = None - test_bulk_edit_objects = None - - # TODO - test_create_object = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -704,11 +975,19 @@ class ConsolePortTestCase(StandardTestCases.Views): 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, - 'connected_endpoint': None, - 'connection_status': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console port', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'New description', } cls.csv_data = ( @@ -719,17 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views): ) -class ConsoleServerPortTestCase(StandardTestCases.Views): +class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort - # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None - test_bulk_edit_objects = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -746,10 +1017,20 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, - 'connection_status': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Server Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console server port', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'device': device.pk, + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'New description', } cls.csv_data = ( @@ -760,17 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): ) -class PowerPortTestCase(StandardTestCases.Views): +class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort - # Disable inapplicable views - test_get_object = None - test_bulk_edit_objects = None - - # TODO - test_create_object = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -789,10 +1062,23 @@ class PowerPortTestCase(StandardTestCases.Views): 'allocated_draw': 50, 'description': 'A power port', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, - 'connection_status': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Port [4-6]]', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'A power port', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'New description', } cls.csv_data = ( @@ -803,17 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views): ) -class PowerOutletTestCase(StandardTestCases.Views): +class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet - # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None - test_bulk_edit_objects = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -838,10 +1116,24 @@ class PowerOutletTestCase(StandardTestCases.Views): 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, - 'connection_status': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Outlet [4-6]', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'A power outlet', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'device': device.pk, + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'New description', } cls.csv_data = ( @@ -852,23 +1144,23 @@ class PowerOutletTestCase(StandardTestCases.Views): ) -class InterfaceTestCase(StandardTestCases.Views): +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): model = Interface - # TODO - test_create_object = None - test_bulk_edit_objects = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') - Interface.objects.bulk_create([ + interfaces = ( Interface(device=device, name='Interface 1'), Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), - ]) + Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + ) + Interface.objects.bulk_create(interfaces) vlans = ( VLAN(vid=1, name='VLAN1', site=device.site), @@ -884,7 +1176,38 @@ class InterfaceTestCase(StandardTestCases.Views): 'name': 'Interface X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, - 'lag': None, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'A front port', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Interface [4-6]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'A front port', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'device': device.pk, + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'mtu': 2000, 'mgmt_only': True, @@ -892,11 +1215,6 @@ class InterfaceTestCase(StandardTestCases.Views): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', - - # Extraneous model fields - 'cable': None, - 'connection_status': None, } cls.csv_data = ( @@ -907,17 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views): ) -class FrontPortTestCase(StandardTestCases.Views): +class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort - # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None - test_bulk_edit_objects = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -946,9 +1256,22 @@ class FrontPortTestCase(StandardTestCases.Views): 'rear_port_position': 1, 'description': 'New description', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Front Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port_set': [ + '{}:1'.format(rp.pk) for rp in rearports[3:6] + ], + 'description': 'New description', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + 'description': 'New description', } cls.csv_data = ( @@ -959,17 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views): ) -class RearPortTestCase(StandardTestCases.Views): +class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort - # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None - test_bulk_edit_objects = None - test_bulk_delete_objects = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -985,11 +1300,22 @@ class RearPortTestCase(StandardTestCases.Views): 'name': 'Rear Port X', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, - 'description': 'New description', + 'description': 'A rear port', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'cable': None, + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Rear Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 3, + 'description': 'A rear port', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.bulk_edit_data = { + 'type': PortTypeChoices.TYPE_8P8C, + 'description': 'New description', } cls.csv_data = ( @@ -1000,16 +1326,11 @@ class RearPortTestCase(StandardTestCases.Views): ) -class DeviceBayTestCase(StandardTestCases.Views): +class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None test_bulk_edit_objects = None - test_bulk_delete_objects = None @classmethod def setUpTestData(cls): @@ -1030,9 +1351,13 @@ class DeviceBayTestCase(StandardTestCases.Views): 'name': 'Device Bay X', 'description': 'A device bay', 'tags': 'Alpha,Bravo,Charlie', + } - # Extraneous model fields - 'installed_device': None, + cls.bulk_create_data = { + 'device': device2.pk, + 'name_pattern': 'Device Bay [4-6]', + 'description': 'A device bay', + 'tags': 'Alpha,Bravo,Charlie', } cls.csv_data = ( @@ -1043,15 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views): ) -class InventoryItemTestCase(StandardTestCases.Views): +class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem - # Disable inapplicable views - test_get_object = None - - # TODO - test_create_object = None - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1076,12 +1395,17 @@ class InventoryItemTestCase(StandardTestCases.Views): 'tags': 'Alpha,Bravo,Charlie', } - cls.csv_data = ( - "device,name", - "Device 1,Inventory Item 4", - "Device 1,Inventory Item 5", - "Device 1,Inventory Item 6", - ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Inventory Item [4-6]', + 'manufacturer': manufacturer.pk, + 'parent': None, + 'discovered': False, + 'part_id': '123456', + 'serial': '123ABC', + 'description': 'An inventory item', + 'tags': 'Alpha,Bravo,Charlie', + } cls.bulk_edit_data = { 'device': device.pk, @@ -1090,8 +1414,15 @@ class InventoryItemTestCase(StandardTestCases.Views): 'description': 'New description', } + cls.csv_data = ( + "device,name", + "Device 1,Inventory Item 4", + "Device 1,Inventory Item 5", + "Device 1,Inventory Item 6", + ) -class CableTestCase(StandardTestCases.Views): + +class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cable # TODO: Creation URL needs termination context @@ -1165,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views): } -class VirtualChassisTestCase(StandardTestCases.Views): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis # Disable inapplicable tests @@ -1219,7 +1550,7 @@ class VirtualChassisTestCase(StandardTestCases.Views): Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) -class PowerPanelTestCase(StandardTestCases.Views): +class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerPanel # Disable inapplicable tests @@ -1260,7 +1591,7 @@ class PowerPanelTestCase(StandardTestCases.Views): ) -class PowerFeedTestCase(StandardTestCases.Views): +class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerFeed @classmethod diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 38e24308b..165ca9e02 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -14,317 +14,338 @@ app_name = 'dcim' urlpatterns = [ # Regions - path(r'regions/', views.RegionListView.as_view(), name='region_list'), - path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'), - path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), - path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), - path(r'regions//edit/', views.RegionEditView.as_view(), name='region_edit'), - path(r'regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path('regions/', views.RegionListView.as_view(), name='region_list'), + path('regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites - path(r'sites/', views.SiteListView.as_view(), name='site_list'), - path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'), - path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), - path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), - path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path(r'sites//', views.SiteView.as_view(), name='site'), - path(r'sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path(r'sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path(r'sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - path(r'sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), + path('sites/', views.SiteListView.as_view(), name='site_list'), + path('sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), + path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), + path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), + path('sites//', views.SiteView.as_view(), name='site'), + path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups - path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), - path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - path(r'rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - path(r'rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), + path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), + path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), + path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles - path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), - path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), - path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), - path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), - path(r'rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), - path(r'rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), + path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations - path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), - path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), - path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), - path(r'rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), - path(r'rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - path(r'rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), + path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), + path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), + path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks - path(r'racks/', views.RackListView.as_view(), name='rack_list'), - path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), - path(r'racks/add/', views.RackCreateView.as_view(), name='rack_add'), - path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), - path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), - path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - path(r'racks//', views.RackView.as_view(), name='rack'), - path(r'racks//edit/', views.RackEditView.as_view(), name='rack_edit'), - path(r'racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), - path(r'racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - path(r'racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), - path(r'racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), + path('racks/', views.RackListView.as_view(), name='rack_list'), + path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), + path('racks/add/', views.RackCreateView.as_view(), name='rack_add'), + path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), + path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), + path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), + path('racks//', views.RackView.as_view(), name='rack'), + path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), + path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), + path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path('racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), + path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers - path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), - path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), - path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), - path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path(r'manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path(r'manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types - path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), - path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), - path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), - path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - path(r'device-types//', views.DeviceTypeView.as_view(), name='devicetype'), - path(r'device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), - path(r'device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - path(r'device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), + path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), + path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), + path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), + path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), + path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates - path(r'device-types//console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), - path(r'device-types//console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), - path(r'console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), + path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), + path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), + path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'), # Console server port templates - path(r'device-types//console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), - path(r'device-types//console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), - path(r'console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), + path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), + path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), + path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'), # Power port templates - path(r'device-types//power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), - path(r'device-types//power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), - path(r'power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), + path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), + path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), + path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'), # Power outlet templates - path(r'device-types//power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), - path(r'device-types//power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), - path(r'power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), + path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), + path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), + path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'), # Interface templates - path(r'device-types//interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), - path(r'device-types//interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), - path(r'device-types//interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), - path(r'interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), + path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), + path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), + path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'), # Front port templates - path(r'device-types//front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), - path(r'device-types//front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), - path(r'front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), + path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), + path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), + path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'), # Rear port templates - path(r'device-types//rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), - path(r'device-types//rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), - path(r'rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), + path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), + path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), + path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'), # Device bay templates - path(r'device-types//device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), - path(r'device-types//device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), - path(r'device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), + # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), + path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), + path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), # Device roles - path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), - path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), - path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), - path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path(r'device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path(r'device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), + path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms - path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'), - path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), - path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), - path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path(r'platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path(r'platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms/', views.PlatformListView.as_view(), name='platform_list'), + path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices - path(r'devices/', views.DeviceListView.as_view(), name='device_list'), - path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'), - path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), - path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), - path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), - path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - path(r'devices//', views.DeviceView.as_view(), name='device'), - path(r'devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), - path(r'devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), - path(r'devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path(r'devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path(r'devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), - path(r'devices//status/', views.DeviceStatusView.as_view(), name='device_status'), - path(r'devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - path(r'devices//config/', views.DeviceConfigView.as_view(), name='device_config'), - path(r'devices//add-secret/', secret_add, name='device_addsecret'), - path(r'devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), - path(r'devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), + path('devices/', views.DeviceListView.as_view(), name='device_list'), + path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), + path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), + path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), + path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), + path('devices//', views.DeviceView.as_view(), name='device'), + path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), + path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), + path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), + path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), + path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path('devices//add-secret/', secret_add, name='device_addsecret'), + path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports - path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), - path(r'devices//console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), - path(r'devices//console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), - path(r'console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), - path(r'console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - path(r'console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - path(r'console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), - path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), + path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), + path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), + path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), + path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), + # TODO: Bulk rename, disconnect views for ConsolePorts + path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), + path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), + path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports - path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), - path(r'devices//console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), - path(r'devices//console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), - path(r'devices//console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'), - path(r'console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), - path(r'console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), - path(r'console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - path(r'console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), - path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), - path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'), + path('console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'), + path('console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + path('console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'), + path('console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), + path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), + path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), + path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), + path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports - path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), - path(r'devices//power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), - path(r'devices//power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'), - path(r'power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), - path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), - path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - path(r'power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), - path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), + path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'), + path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), + path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), + path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), + # TODO: Bulk rename, disconnect views for PowerPorts + path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), + path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), + path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets - path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), - path(r'devices//power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), - path(r'devices//power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), - path(r'devices//power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'), - path(r'power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), - path(r'power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), - path(r'power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), - path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), - path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'), + path('power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'), + path('power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + path('power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'), + path('power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), + path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), + path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), + path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), + path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces - path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), - path(r'devices//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), - path(r'devices//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - path(r'devices//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'), - path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), - path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), - path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), - path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), - path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'), + path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'), + path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), + path('interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'), + path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path('interfaces//', views.InterfaceView.as_view(), name='interface'), + path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), + path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), + path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), + path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports - # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), - path(r'devices//front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), - path(r'devices//front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), - path(r'devices//front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'), - path(r'front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), - path(r'front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), - path(r'front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), - path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), - path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'), + path('front-ports/', views.FrontPortListView.as_view(), name='frontport_list'), + path('front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), + path('front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'), + path('front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), + path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), + path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports - # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), - path(r'devices//rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), - path(r'devices//rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), - path(r'devices//rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'), - path(r'rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), - path(r'rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), - path(r'rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), - path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), - path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), - path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'), + path('rear-ports/', views.RearPortListView.as_view(), name='rearport_list'), + path('rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), + path('rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'), + path('rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), + path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), + path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), + path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Device bays - path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), - path(r'devices//bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), - path(r'devices//bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), - path(r'device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - path(r'device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - path(r'device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), - path(r'device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), - path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), - path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'), + path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), + path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), + path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'), + # TODO: Bulk edit view for DeviceBays + path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), + path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), + path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), + path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), # Inventory items - path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), - path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), - path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), - path(r'inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), - path(r'inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - path(r'devices//inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + path('inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), + path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'), + path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), + path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), + # TODO: Bulk rename view for InventoryItems + path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), # Cables - path(r'cables/', views.CableListView.as_view(), name='cable_list'), - path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), - path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), - path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), - path(r'cables//', views.CableView.as_view(), name='cable'), - path(r'cables//edit/', views.CableEditView.as_view(), name='cable_edit'), - path(r'cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), - path(r'cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path('cables/', views.CableListView.as_view(), name='cable_list'), + path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), + path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + path('cables//', views.CableView.as_view(), name='cable'), + path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), + path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), + path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), # Console/power/interface connections (read-only) - path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), + path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), + path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), + path('interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), # Virtual chassis - path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), - path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), - path(r'virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), - path(r'virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - path(r'virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - path(r'virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), - path(r'virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), # Power panels - path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), - path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), - path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), - path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), - path(r'power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), - path(r'power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), - path(r'power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), - path(r'power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), + path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), + path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), + path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), + path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), + path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), + path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), # Power feeds - path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path(r'power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), - path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), - path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), - path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), - path(r'power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), - path(r'power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), - path(r'power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), - path(r'power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), + path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), + path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), + path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), + path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), + path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), + path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fd3d09ab7..ae59890a3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -700,13 +700,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Device type components +# Console port templates # class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = ConsolePortTemplate form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm @@ -719,17 +717,30 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.ConsolePortTemplateForm +class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleporttemplate' + model = ConsolePortTemplate + + +class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleporttemplate' + queryset = ConsolePortTemplate.objects.all() + table = tables.ConsolePortTemplateTable + form = forms.ConsolePortTemplateBulkEditForm + + class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleporttemplate' queryset = ConsolePortTemplate.objects.all() - parent_model = DeviceType table = tables.ConsolePortTemplateTable +# +# Console server port templates +# + class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = ConsoleServerPortTemplate form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm @@ -742,17 +753,30 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView) model_form = forms.ConsoleServerPortTemplateForm +class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleserverporttemplate' + model = ConsoleServerPortTemplate + + +class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleserverporttemplate' + queryset = ConsoleServerPortTemplate.objects.all() + table = tables.ConsoleServerPortTemplateTable + form = forms.ConsoleServerPortTemplateBulkEditForm + + class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverporttemplate' queryset = ConsoleServerPortTemplate.objects.all() - parent_model = DeviceType table = tables.ConsoleServerPortTemplateTable +# +# Power port templates +# + class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = PowerPortTemplate form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm @@ -765,17 +789,30 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerPortTemplateForm +class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerporttemplate' + model = PowerPortTemplate + + +class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerporttemplate' + queryset = PowerPortTemplate.objects.all() + table = tables.PowerPortTemplateTable + form = forms.PowerPortTemplateBulkEditForm + + class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' queryset = PowerPortTemplate.objects.all() - parent_model = DeviceType table = tables.PowerPortTemplateTable +# +# Power outlet templates +# + class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' - parent_model = DeviceType - parent_field = 'device_type' model = PowerOutletTemplate form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm @@ -788,17 +825,30 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerOutletTemplateForm +class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_poweroutlettemplate' + model = PowerOutletTemplate + + +class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_poweroutlettemplate' + queryset = PowerOutletTemplate.objects.all() + table = tables.PowerOutletTemplateTable + form = forms.PowerOutletTemplateBulkEditForm + + class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlettemplate' queryset = PowerOutletTemplate.objects.all() - parent_model = DeviceType table = tables.PowerOutletTemplateTable +# +# Interface templates +# + class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' - parent_model = DeviceType - parent_field = 'device_type' model = InterfaceTemplate form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm @@ -811,10 +861,14 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.InterfaceTemplateForm +class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interfacetemplate' + model = InterfaceTemplate + + class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' queryset = InterfaceTemplate.objects.all() - parent_model = DeviceType table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm @@ -822,14 +876,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' queryset = InterfaceTemplate.objects.all() - parent_model = DeviceType table = tables.InterfaceTemplateTable +# +# Front port templates +# + class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = FrontPortTemplate form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm @@ -842,17 +897,30 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.FrontPortTemplateForm +class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + model = FrontPortTemplate + + +class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_frontporttemplate' + queryset = FrontPortTemplate.objects.all() + table = tables.FrontPortTemplateTable + form = forms.FrontPortTemplateBulkEditForm + + class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_frontporttemplate' queryset = FrontPortTemplate.objects.all() - parent_model = DeviceType table = tables.FrontPortTemplateTable +# +# Rear port templates +# + class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearporttemplate' - parent_model = DeviceType - parent_field = 'device_type' model = RearPortTemplate form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm @@ -865,17 +933,30 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.RearPortTemplateForm +class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + model = RearPortTemplate + + +class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_rearporttemplate' + queryset = RearPortTemplate.objects.all() + table = tables.RearPortTemplateTable + form = forms.RearPortTemplateBulkEditForm + + class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rearporttemplate' queryset = RearPortTemplate.objects.all() - parent_model = DeviceType table = tables.RearPortTemplateTable +# +# Device bay templates +# + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' - parent_model = DeviceType - parent_field = 'device_type' model = DeviceBayTemplate form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm @@ -888,10 +969,21 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.DeviceBayTemplateForm +class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_devicebaytemplate' + model = DeviceBayTemplate + + +# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): +# permission_required = 'dcim.change_devicebaytemplate' +# queryset = DeviceBayTemplate.objects.all() +# table = tables.DeviceBayTemplateTable +# form = forms.DeviceBayTemplateBulkEditForm + + class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' queryset = DeviceBayTemplate.objects.all() - parent_model = DeviceType table = tables.DeviceBayTemplateTable @@ -1200,13 +1292,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/consoleport_list.html' class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleport' - parent_model = Device - parent_field = 'device' model = ConsolePort form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm @@ -1231,11 +1321,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:consoleport_list' +class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleport' + queryset = ConsolePort.objects.all() + table = tables.ConsolePortTable + form = forms.ConsolePortBulkEditForm + + class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' queryset = ConsolePort.objects.all() - parent_model = Device table = tables.ConsolePortTable + default_return_url = 'dcim:consoleport_list' # @@ -1248,13 +1345,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/consoleserverport_list.html' class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverport' - parent_model = Device - parent_field = 'device' model = ConsoleServerPort form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm @@ -1282,7 +1377,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleserverport' queryset = ConsoleServerPort.objects.all() - parent_model = Device table = tables.ConsoleServerPortTable form = forms.ConsoleServerPortBulkEditForm @@ -1302,8 +1396,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' queryset = ConsoleServerPort.objects.all() - parent_model = Device table = tables.ConsoleServerPortTable + default_return_url = 'dcim:consoleserverport_list' # @@ -1316,13 +1410,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/powerport_list.html' class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerport' - parent_model = Device - parent_field = 'device' model = PowerPort form = forms.PowerPortCreateForm model_form = forms.PowerPortForm @@ -1347,11 +1439,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:powerport_list' +class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerport' + queryset = PowerPort.objects.all() + table = tables.PowerPortTable + form = forms.PowerPortBulkEditForm + + class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' queryset = PowerPort.objects.all() - parent_model = Device table = tables.PowerPortTable + default_return_url = 'dcim:powerport_list' # @@ -1364,13 +1463,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/poweroutlet_list.html' class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlet' - parent_model = Device - parent_field = 'device' model = PowerOutlet form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm @@ -1398,7 +1495,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_poweroutlet' queryset = PowerOutlet.objects.all() - parent_model = Device table = tables.PowerOutletTable form = forms.PowerOutletBulkEditForm @@ -1418,8 +1514,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' queryset = PowerOutlet.objects.all() - parent_model = Device table = tables.PowerOutletTable + default_return_url = 'dcim:poweroutlet_list' # @@ -1432,7 +1528,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/interface_list.html' class InterfaceView(PermissionRequiredMixin, View): @@ -1473,8 +1569,6 @@ class InterfaceView(PermissionRequiredMixin, View): class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interface' - parent_model = Device - parent_field = 'device' model = Interface form = forms.InterfaceCreateForm model_form = forms.InterfaceForm @@ -1503,7 +1597,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' queryset = Interface.objects.all() - parent_model = Device table = tables.InterfaceTable form = forms.InterfaceBulkEditForm @@ -1523,8 +1616,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' queryset = Interface.objects.all() - parent_model = Device table = tables.InterfaceTable + default_return_url = 'dcim:interface_list' # @@ -1537,13 +1630,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/frontport_list.html' class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontport' - parent_model = Device - parent_field = 'device' model = FrontPort form = forms.FrontPortCreateForm model_form = forms.FrontPortForm @@ -1571,7 +1662,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_frontport' queryset = FrontPort.objects.all() - parent_model = Device table = tables.FrontPortTable form = forms.FrontPortBulkEditForm @@ -1591,8 +1681,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_frontport' queryset = FrontPort.objects.all() - parent_model = Device table = tables.FrontPortTable + default_return_url = 'dcim:frontport_list' # @@ -1605,13 +1695,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/rearport_list.html' class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearport' - parent_model = Device - parent_field = 'device' model = RearPort form = forms.RearPortCreateForm model_form = forms.RearPortForm @@ -1639,7 +1727,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rearport' queryset = RearPort.objects.all() - parent_model = Device table = tables.RearPortTable form = forms.RearPortBulkEditForm @@ -1659,8 +1746,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rearport' queryset = RearPort.objects.all() - parent_model = Device table = tables.RearPortTable + default_return_url = 'dcim:rearport_list' # @@ -1675,13 +1762,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/devicebay_list.html' class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebay' - parent_model = Device - parent_field = 'device' model = DeviceBay form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm @@ -1784,8 +1869,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' queryset = DeviceBay.objects.all() - parent_model = Device table = tables.DeviceBayTable + default_return_url = 'dcim:devicebay_list' # @@ -2156,13 +2241,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): model = InventoryItem model_form = forms.InventoryItemForm - def alter_obj(self, obj, request, url_args, url_kwargs): - if 'device' in url_kwargs: - obj.device = get_object_or_404(Device, pk=url_kwargs['device']) - return obj - def get_return_url(self, request, obj): - return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) +class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_inventoryitem' + model = InventoryItem + form = forms.InventoryItemCreateForm + model_form = forms.InventoryItemForm + template_name = 'dcim/device_component_add.html' class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 50a54d3fe..d699cd22e 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -15,34 +15,34 @@ router = routers.DefaultRouter() router.APIRootView = ExtrasRootView # Field choices -router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') # Custom field choices -router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') +router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') # Graphs -router.register(r'graphs', views.GraphViewSet) +router.register('graphs', views.GraphViewSet) # Export templates -router.register(r'export-templates', views.ExportTemplateViewSet) +router.register('export-templates', views.ExportTemplateViewSet) # Tags -router.register(r'tags', views.TagViewSet) +router.register('tags', views.TagViewSet) # Image attachments -router.register(r'image-attachments', views.ImageAttachmentViewSet) +router.register('image-attachments', views.ImageAttachmentViewSet) # Config contexts -router.register(r'config-contexts', views.ConfigContextViewSet) +router.register('config-contexts', views.ConfigContextViewSet) # Reports -router.register(r'reports', views.ReportViewSet, basename='report') +router.register('reports', views.ReportViewSet, basename='report') # Scripts -router.register(r'scripts', views.ScriptViewSet, basename='script') +router.register('scripts', views.ScriptViewSet, basename='script') # Change logging -router.register(r'object-changes', views.ObjectChangeViewSet) +router.register('object-changes', views.ObjectChangeViewSet) app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index dd21b3d92..d6a5406b7 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,14 +1,15 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2, - BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -133,7 +134,8 @@ class CustomFieldFilterForm(forms.Form): filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) for cf in custom_fields: - self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False) + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) # @@ -189,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): - tags = forms.ModelMultipleChoiceField( + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2Multiple() + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/sites/" + ) + ) + roles = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/" + ) + ) + platforms = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/platforms/" + ) + ) + cluster_groups = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ) + ) + clusters = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/" + ) + ) + tenant_groups = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/" + ) + ) + tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/" + ) + ) + tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', required=False, @@ -203,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext - fields = [ + fields = ( 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', - ] - widgets = { - 'regions': APISelectMultiple( - api_url="/api/dcim/regions/" - ), - 'sites': APISelectMultiple( - api_url="/api/dcim/sites/" - ), - 'roles': APISelectMultiple( - api_url="/api/dcim/device-roles/" - ), - 'platforms': APISelectMultiple( - api_url="/api/dcim/platforms/" - ), - 'cluster_groups': APISelectMultiple( - api_url="/api/virtualization/cluster-groups/" - ), - 'clusters': APISelectMultiple( - api_url="/api/virtualization/clusters/" - ), - 'tenant_groups': APISelectMultiple( - api_url="/api/tenancy/tenant-groups/" - ), - 'tenants': APISelectMultiple( - api_url="/api/tenancy/tenants/" - ), - } + ) class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): @@ -264,72 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", ) ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", value_field="slug", ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url="/api/virtualization/clusters/", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", ) ) - tag = FilterChoiceField( + tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/extras/tags/", value_field="slug", @@ -386,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): ) action = forms.ChoiceField( choices=add_blank_choice(ObjectChangeActionChoices), - required=False + required=False, + widget=StaticSelect2() ) + # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), - required=False + required=False, + widget=StaticSelect2() ) changed_object_type = forms.ModelChoiceField( queryset=ContentType.objects.order_by('model'), diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py new file mode 100644 index 000000000..70f57c1ba --- /dev/null +++ b/netbox/extras/management/commands/renaturalize.py @@ -0,0 +1,111 @@ +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError + +from utilities.fields import NaturalOrderingField + + +class Command(BaseCommand): + help = "Recalculate natural ordering values for the specified models" + + def add_arguments(self, parser): + parser.add_argument( + 'args', metavar='app_label.ModelName', nargs='*', + help='One or more specific models (each prefixed with its app_label) to renaturalize', + ) + + def _get_models(self, names): + """ + Compile a list of models to be renaturalized. If no names are specified, all models which have one or more + NaturalOrderingFields will be included. + """ + models = [] + + if names: + # Collect all NaturalOrderingFields present on the specified models + for name in names: + try: + app_label, model_name = name.split('.') + except ValueError: + raise CommandError( + "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name) + ) + try: + app_config = apps.get_app_config(app_label) + except LookupError as e: + raise CommandError(str(e)) + try: + model = app_config.get_model(model_name) + except LookupError: + raise CommandError("Unknown model: {}.{}".format(app_label, model_name)) + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if not fields: + raise CommandError( + "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name) + ) + models.append( + (model, fields) + ) + + else: + # Find *all* models with NaturalOrderingFields + for app_config in apps.get_app_configs(): + for model in app_config.models.values(): + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if fields: + models.append( + (model, fields) + ) + + return models + + def handle(self, *args, **options): + + models = self._get_models(args) + + if options['verbosity']: + self.stdout.write("Renaturalizing {} models.".format(len(models))) + + for model, fields in models: + for field in fields: + target_field = field.target_field + naturalize = field.naturalize_function + count = 0 + + # Print the model and field name + if options['verbosity']: + self.stdout.write( + "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name), + ending='\n' if options['verbosity'] >= 2 else '' + ) + self.stdout.flush() + + # Find all unique values for the field + queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct() + for value in queryset: + naturalized_value = naturalize(value) + + if options['verbosity'] >= 2: + self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') + self.stdout.flush() + + # Update each unique field value in bulk + changed = model.objects.filter(name=value).update(**{field.name: naturalized_value}) + + if options['verbosity'] >= 2: + self.stdout.write(" ({})".format(changed)) + count += changed + + # Print the total count of alterations for the field + if options['verbosity'] >= 2: + self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format( + count, model._meta.verbose_name_plural, queryset.count() + ))) + elif options['verbosity']: + self.stdout.write(self.style.SUCCESS(str(count))) + + if options['verbosity']: + self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 6567fe707..e5a32bde6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -48,7 +48,7 @@ class ScriptVariable: """ form_field = forms.CharField - def __init__(self, label='', description='', default=None, required=True): + def __init__(self, label='', description='', default=None, required=True, widget=None): # Initialize field attributes if not hasattr(self, 'field_attrs'): @@ -59,6 +59,8 @@ class ScriptVariable: self.field_attrs['help_text'] = description if default: self.field_attrs['initial'] = default + if widget: + self.field_attrs['widget'] = widget self.field_attrs['required'] = required # Initialize the list of optional validators if none have already been defined @@ -71,7 +73,10 @@ class ScriptVariable: """ form_field = self.form_field(**self.field_attrs) if not isinstance(form_field.widget, forms.CheckboxInput): - form_field.widget.attrs['class'] = 'form-control' + if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys(): + form_field.widget.attrs['class'] += ' form-control' + else: + form_field.widget.attrs['class'] = 'form-control' return form_field diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ecb25a78c..370055b26 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -7,10 +7,10 @@ from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import ConfigContext, ObjectChange, Tag -from utilities.testing import StandardTestCases, TestCase +from utilities.testing import ViewTestCases, TestCase -class TagTestCase(StandardTestCases.Views): +class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tag # Disable inapplicable tests @@ -38,7 +38,7 @@ class TagTestCase(StandardTestCases.Views): } -class ConfigContextTestCase(StandardTestCases.Views): +class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ConfigContext # Disable inapplicable tests diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 653fe7c7f..a486ce7fc 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -8,38 +8,38 @@ app_name = 'extras' urlpatterns = [ # Tags - path(r'tags/', views.TagListView.as_view(), name='tag_list'), - path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), - path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path(r'tags//', views.TagView.as_view(), name='tag'), - path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path('tags/', views.TagListView.as_view(), name='tag_list'), + path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), + path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path('tags//', views.TagView.as_view(), name='tag'), + path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts - path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), - path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), - path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), - path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), - path(r'config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), - path(r'config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), - path(r'config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), + path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), + path('config-contexts/add/', views.ConfigContextCreateView.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//', views.ConfigContextView.as_view(), name='configcontext'), + path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), # Image attachments - path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), - path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), # Change logging - path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path('changelog//', views.ObjectChangeView.as_view(), name='objectchange'), # Reports - path(r'reports/', views.ReportListView.as_view(), name='report_list'), - path(r'reports//', views.ReportView.as_view(), name='report'), - path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), + path('reports/', views.ReportListView.as_view(), name='report_list'), + path('reports//', views.ReportView.as_view(), name='report'), + path('reports//run/', views.ReportRunView.as_view(), name='report_run'), # Scripts - path(r'scripts/', views.ScriptListView.as_view(), name='script_list'), - path(r'scripts///', views.ScriptView.as_view(), name='script'), + path('scripts/', views.ScriptListView.as_view(), name='script_list'), + path('scripts///', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e52c172e5..e6d9adecd 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) @@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceField(choices=ServiceProtocolChoices) + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=NestedIPAddressSerializer, diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 9a2e1bc1f..c4d68f9c0 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -15,30 +15,30 @@ router = routers.DefaultRouter() router.APIRootView = IPAMRootView # Field choices -router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') # VRFs -router.register(r'vrfs', views.VRFViewSet) +router.register('vrfs', views.VRFViewSet) # RIRs -router.register(r'rirs', views.RIRViewSet) +router.register('rirs', views.RIRViewSet) # Aggregates -router.register(r'aggregates', views.AggregateViewSet) +router.register('aggregates', views.AggregateViewSet) # Prefixes -router.register(r'roles', views.RoleViewSet) -router.register(r'prefixes', views.PrefixViewSet) +router.register('roles', views.RoleViewSet) +router.register('prefixes', views.PrefixViewSet) # IP addresses -router.register(r'ip-addresses', views.IPAddressViewSet) +router.register('ip-addresses', views.IPAddressViewSet) # VLANs -router.register(r'vlan-groups', views.VLANGroupViewSet) -router.register(r'vlans', views.VLANViewSet) +router.register('vlan-groups', views.VLANGroupViewSet) +router.register('vlans', views.VLANViewSet) # Services -router.register(r'services', views.ServiceViewSet) +router.register('services', views.ServiceViewSet) app_name = 'ipam-api' urlpatterns = router.urls diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..5f8bcabff 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -304,12 +304,12 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.CharFilter( + device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device', + label='Device (name)', ) - device_id = django_filters.NumberFilter( + device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', label='Device (ID)', @@ -385,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF def filter_device(self, queryset, name, value): try: - device = Device.objects.prefetch_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] + devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) + vc_interface_ids = [] + for device in devices: + vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) return queryset.filter(interface_id__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 24f044f79..2b7fb2a6b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,9 +10,10 @@ from extras.forms import ( from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, - CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -75,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -148,6 +149,12 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + widget=APISelect( + api_url="/api/ipam/rirs/" + ) + ) tags = TagField( required=False ) @@ -162,9 +169,6 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): 'rir': "Regional Internet Registry responsible for this prefix", } widgets = { - 'rir': APISelect( - api_url="/api/ipam/rirs/" - ), 'date_added': DatePicker(), } @@ -189,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() ) - rir = forms.ModelChoiceField( + rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, label='RIR', @@ -226,9 +230,10 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Address family', widget=StaticSelect2() ) - rir = FilterChoiceField( + rir = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), to_field_name='slug', + required=False, label='RIR', widget=APISelectMultiple( api_url="/api/ipam/rirs/", @@ -268,10 +273,16 @@ class RoleCSVForm(forms.ModelForm): # class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vrfs/", + ) + ) + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', widget=APISelect( api_url="/api/dcim/sites/", filter_for={ @@ -283,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan_group = ChainedModelChoiceField( + vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, label='VLAN group', widget=APISelect( @@ -300,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan = ChainedModelChoiceField( + vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), required=False, label='VLAN', widget=APISelect( @@ -313,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name' ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -322,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'tags', ] widgets = { - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } def __init__(self, *args, **kwargs): @@ -439,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -459,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF max_value=PREFIX_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -471,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -525,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -539,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -551,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -594,7 +599,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel queryset=Interface.objects.all(), required=False ) - nat_site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', @@ -606,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_rack = ChainedModelChoiceField( + nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'nat_site'), - ), required=False, label='Rack', widget=APISelect( @@ -624,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_device = ChainedModelChoiceField( + nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'nat_site'), - ('rack', 'nat_rack'), - ), required=False, label='Device', widget=APISelect( @@ -651,11 +657,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_inside = ChainedModelChoiceField( + nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - chains=( - ('interface__device', 'nat_device'), - ), required=False, label='IP Address', widget=APISelect( @@ -680,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -757,6 +757,14 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) class Meta: model = IPAddress @@ -766,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -904,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -917,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -950,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf_id = forms.ModelChoiceField( + vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -996,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -1030,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: @@ -1037,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'name', 'slug', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } class VLANGroupCSVForm(forms.ModelForm): @@ -1065,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1077,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1094,7 +1101,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -1107,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, - label='Group', widget=APISelect( api_url='/api/ipam/vlan-groups/', ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -1135,9 +1145,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } @@ -1212,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlan-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -1238,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -1263,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1276,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), + required=False, label='VLAN group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", null_option=True, @@ -1300,10 +1307,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 80ada0df8..4737a0f53 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # TODO: Test for multiple values def test_device(self): - device = Device.objects.first() - params = {'device_id': device.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'device': device.name} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_virtual_machine(self): vms = VirtualMachine.objects.all()[:2] diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py new file mode 100644 index 000000000..153bedddc --- /dev/null +++ b/netbox/ipam/tests/test_ordering.py @@ -0,0 +1,176 @@ +from django.test import TestCase + +from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices +from ipam.models import IPAddress, Prefix, VRF + +import netaddr + + +class OrderingTestBase(TestCase): + vrfs = None + + def setUp(self): + """ + Setup the VRFs for the class as a whole + """ + self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) + VRF.objects.bulk_create(self.vrfs) + + def _compare(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + for i, obj in enumerate(queryset): + self.assertEqual(obj, objectset[i]) + + def _compare_ne(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + for i, obj in enumerate(queryset): + self.assertNotEqual(obj, objectset[i]) + + +class PrefixOrderingTestCase(OrderingTestBase): + + def test_prefix_vrf_ordering(self): + """ + This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Prefixes + prefixes = ( + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.5.0/24')), + + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.4.0/24')), + + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/12')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.4.0/24')), + ) + + Prefix.objects.bulk_create(prefixes) + + # Test + self._compare(Prefix.objects.all(), prefixes) + + def test_prefix_complex_ordering(self): + """ + This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs + This includes the testing of the Container status. + + The proper ordering, to get proper containerization should be: + None:10.0.0.0/8 + None:10.0.0.0/16 + VRF A:10.0.0.0/24 + VRF A:10.0.1.0/24 + VRF A:10.0.1.0/25 + None:10.1.0.0/16 + VRF A:10.1.0.0/24 + VRF A:10.1.1.0/24 + None: 192.168.0.0/16 + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Prefixes + prefixes = [ + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/25')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), + ] + Prefix.objects.bulk_create(prefixes) + + # Test + self._compare(Prefix.objects.all(), prefixes) + + +class IPAddressOrderingTestCase(OrderingTestBase): + + def test_address_vrf_ordering(self): + """ + This function tests ordering with the inclusion of vrfs + """ + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Addresses + addresses = ( + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.4.1/24')), + + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.4.1/24')), + + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.5.1/24')), + ) + IPAddress.objects.bulk_create(addresses) + + # Test + self._compare(IPAddress.objects.all(), addresses) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index cfa06788c..66e649005 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -5,10 +5,10 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class VRFTestCase(StandardTestCases.Views): +class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VRF @classmethod @@ -43,14 +43,9 @@ class VRFTestCase(StandardTestCases.Views): } -class RIRTestCase(StandardTestCases.Views): +class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RIR - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -74,7 +69,7 @@ class RIRTestCase(StandardTestCases.Views): ) -class AggregateTestCase(StandardTestCases.Views): +class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Aggregate @classmethod @@ -115,14 +110,9 @@ class AggregateTestCase(StandardTestCases.Views): } -class RoleTestCase(StandardTestCases.Views): +class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Role - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -147,7 +137,7 @@ class RoleTestCase(StandardTestCases.Views): ) -class PrefixTestCase(StandardTestCases.Views): +class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Prefix @classmethod @@ -207,7 +197,7 @@ class PrefixTestCase(StandardTestCases.Views): } -class IPAddressTestCase(StandardTestCases.Views): +class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPAddress @classmethod @@ -254,14 +244,9 @@ class IPAddressTestCase(StandardTestCases.Views): } -class VLANGroupTestCase(StandardTestCases.Views): +class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = VLANGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -287,7 +272,7 @@ class VLANGroupTestCase(StandardTestCases.Views): ) -class VLANTestCase(StandardTestCases.Views): +class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VLAN @classmethod @@ -346,7 +331,7 @@ class VLANTestCase(StandardTestCases.Views): } -class ServiceTestCase(StandardTestCases.Views): +class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service # Disable inapplicable tests diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 2a1dcdf05..604287f24 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -8,97 +8,97 @@ app_name = 'ipam' urlpatterns = [ # VRFs - path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'), - path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), - path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), - path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), - path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - path(r'vrfs//', views.VRFView.as_view(), name='vrf'), - path(r'vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), - path(r'vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), - path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), + path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), + path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), + path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), + path('vrfs//', views.VRFView.as_view(), name='vrf'), + path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), + path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), + path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs - path(r'rirs/', views.RIRListView.as_view(), name='rir_list'), - path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), - path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), - path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - path(r'rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), - path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs/', views.RIRListView.as_view(), name='rir_list'), + path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates - path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), - path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), - path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), - path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), - path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - path(r'aggregates//', views.AggregateView.as_view(), name='aggregate'), - path(r'aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), - path(r'aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - path(r'aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), + path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), + path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), + path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), + path('aggregates//', views.AggregateView.as_view(), name='aggregate'), + path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), + path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles - path(r'roles/', views.RoleListView.as_view(), name='role_list'), - path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'), - path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), - path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - path(r'roles//edit/', views.RoleEditView.as_view(), name='role_edit'), - path(r'roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles/', views.RoleListView.as_view(), name='role_list'), + path('roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes - path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'), - path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), - path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), - path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), - path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - path(r'prefixes//', views.PrefixView.as_view(), name='prefix'), - path(r'prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), - path(r'prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path(r'prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - path(r'prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - path(r'prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), + path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), + path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), + path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), + path('prefixes//', views.PrefixView.as_view(), name='prefix'), + path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), + path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), + path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), + path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), # IP addresses - path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), - path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), - path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), - path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), - path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), - path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - path(r'ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), - path(r'ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), - path(r'ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - path(r'ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), + path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), + path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), + path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), + path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + path('ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), + path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), + path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), + path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups - path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), - path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), - path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), - path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), - path(r'vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), - path(r'vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), - path(r'vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), + path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), + path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs - path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'), - path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), - path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), - path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), - path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - path(r'vlans//', views.VLANView.as_view(), name='vlan'), - path(r'vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), - path(r'vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), - path(r'vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), - path(r'vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path('vlans/', views.VLANListView.as_view(), name='vlan_list'), + path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), + path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), + path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), + path('vlans//', views.VLANView.as_view(), name='vlan'), + path('vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), + path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), + path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services - path(r'services/', views.ServiceListView.as_view(), name='service_list'), - path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), - path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), - path(r'services//', views.ServiceView.as_view(), name='service'), - path(r'services//edit/', views.ServiceEditView.as_view(), name='service_edit'), - path(r'services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), - path(r'services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path('services/', views.ServiceListView.as_view(), name='service_list'), + path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + path('services//', views.ServiceView.as_view(), name='service'), + path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), + path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), + path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index c1258d83b..7002def9b 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -10,7 +10,8 @@ # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] ALLOWED_HOSTS = [] -# PostgreSQL database configuration. +# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: +# https://docs.djangoproject.com/en/stable/ref/settings/#databases DATABASE = { 'NAME': 'netbox', # Database name 'USER': '', # PostgreSQL username @@ -27,6 +28,9 @@ REDIS = { 'webhooks': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, @@ -35,6 +39,9 @@ REDIS = { 'caching': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, 'DEFAULT_TIMEOUT': 300, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 13dae00a5..5f60d16f7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.4' +VERSION = '2.7.5' # Hostname HOSTNAME = platform.node() @@ -170,18 +170,31 @@ if 'caching' not in REDIS: WEBHOOKS_REDIS = REDIS.get('webhooks', {}) WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) +WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', []) +WEBHOOKS_REDIS_USING_SENTINEL = all([ + isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)), + len(WEBHOOKS_REDIS_SENTINELS) > 0 +]) +WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default') WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) + CACHING_REDIS = REDIS.get('caching', {}) -CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') -CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) -CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') -CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) -CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) -CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) +CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') +CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) +CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', []) +CACHING_REDIS_USING_SENTINEL = all([ + isinstance(CACHING_REDIS_SENTINELS, (list, tuple)), + len(CACHING_REDIS_SENTINELS) > 0 +]) +CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') +CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') +CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) +CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) +CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) # @@ -394,28 +407,35 @@ if LDAP_CONFIG is not None: # # Caching # - -if CACHING_REDIS_SSL: - REDIS_CACHE_CON_STRING = 'rediss://' +if CACHING_REDIS_USING_SENTINEL: + CACHEOPS_SENTINEL = { + 'locations': CACHING_REDIS_SENTINELS, + 'service_name': CACHING_REDIS_SENTINEL_SERVICE, + 'db': CACHING_REDIS_DATABASE, + } else: - REDIS_CACHE_CON_STRING = 'redis://' + if CACHING_REDIS_SSL: + REDIS_CACHE_CON_STRING = 'rediss://' + else: + REDIS_CACHE_CON_STRING = 'redis://' -if CACHING_REDIS_PASSWORD: - REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) + if CACHING_REDIS_PASSWORD: + REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) -REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( - REDIS_CACHE_CON_STRING, - CACHING_REDIS_HOST, - CACHING_REDIS_PORT, - CACHING_REDIS_DATABASE -) + REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( + REDIS_CACHE_CON_STRING, + CACHING_REDIS_HOST, + CACHING_REDIS_PORT, + CACHING_REDIS_DATABASE + ) + CACHEOPS_REDIS = REDIS_CACHE_CON_STRING if not CACHE_TIMEOUT: CACHEOPS_ENABLED = False else: CACHEOPS_ENABLED = True -CACHEOPS_REDIS = REDIS_CACHE_CON_STRING + CACHEOPS_DEFAULTS = { 'timeout': CACHE_TIMEOUT } @@ -534,6 +554,15 @@ RQ_QUEUES = { 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT, 'SSL': WEBHOOKS_REDIS_SSL, + } if not WEBHOOKS_REDIS_USING_SENTINEL else { + 'SENTINELS': WEBHOOKS_REDIS_SENTINELS, + 'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE, + 'DB': WEBHOOKS_REDIS_DATABASE, + 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, + 'SOCKET_TIMEOUT': None, + 'CONNECTION_KWARGS': { + 'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT + }, } } diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 66ab982eb..2c4d504b2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -26,49 +26,49 @@ schema_view = get_schema_view( _patterns = [ # Base views - path(r'', HomeView.as_view(), name='home'), - path(r'search/', SearchView.as_view(), name='search'), + path('', HomeView.as_view(), name='home'), + path('search/', SearchView.as_view(), name='search'), # Login/logout - path(r'login/', LoginView.as_view(), name='login'), - path(r'logout/', LogoutView.as_view(), name='logout'), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), # Apps - path(r'circuits/', include('circuits.urls')), - path(r'dcim/', include('dcim.urls')), - path(r'extras/', include('extras.urls')), - path(r'ipam/', include('ipam.urls')), - path(r'secrets/', include('secrets.urls')), - path(r'tenancy/', include('tenancy.urls')), - path(r'user/', include('users.urls')), - path(r'virtualization/', include('virtualization.urls')), + path('circuits/', include('circuits.urls')), + path('dcim/', include('dcim.urls')), + path('extras/', include('extras.urls')), + path('ipam/', include('ipam.urls')), + path('secrets/', include('secrets.urls')), + path('tenancy/', include('tenancy.urls')), + path('user/', include('users.urls')), + path('virtualization/', include('virtualization.urls')), # API - path(r'api/', APIRootView.as_view(), name='api-root'), - path(r'api/circuits/', include('circuits.api.urls')), - path(r'api/dcim/', include('dcim.api.urls')), - path(r'api/extras/', include('extras.api.urls')), - path(r'api/ipam/', include('ipam.api.urls')), - path(r'api/secrets/', include('secrets.api.urls')), - path(r'api/tenancy/', include('tenancy.api.urls')), - path(r'api/virtualization/', include('virtualization.api.urls')), - path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'), - path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), + path('api/', APIRootView.as_view(), name='api-root'), + path('api/circuits/', include('circuits.api.urls')), + path('api/dcim/', include('dcim.api.urls')), + path('api/extras/', include('extras.api.urls')), + path('api/ipam/', include('ipam.api.urls')), + path('api/secrets/', include('secrets.api.urls')), + path('api/tenancy/', include('tenancy.api.urls')), + path('api/virtualization/', include('virtualization.api.urls')), + path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), + path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware - path(r'media/', serve, {'document_root': settings.MEDIA_ROOT}), + path('media/', serve, {'document_root': settings.MEDIA_ROOT}), # Admin - path(r'admin/', admin_site.urls), - path(r'admin/webhook-backend-status/', include('django_rq.urls')), + path('admin/', admin_site.urls), + path('admin/webhook-backend-status/', include('django_rq.urls')), ] if settings.DEBUG: import debug_toolbar _patterns += [ - path(r'__debug__/', include(debug_toolbar.urls)), + path('__debug__/', include(debug_toolbar.urls)), ] if settings.METRICS_ENABLED: @@ -78,7 +78,7 @@ if settings.METRICS_ENABLED: # Prepend BASE_PATH urlpatterns = [ - path(r'{}'.format(settings.BASE_PATH), include(_patterns)) + path('{}'.format(settings.BASE_PATH), include(_patterns)) ] handler500 = 'utilities.views.server_error' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index fbe70300b..904dc7375 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -252,7 +252,7 @@ class HomeView(View): 'search_form': SearchForm(), 'stats': stats, 'report_results': ReportResult.objects.order_by('-created')[:10], - 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50] + 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15] }) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 45babe70b..456eeab6f 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -62,8 +62,20 @@ footer p { } } +/* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */ +@media (min-width: 768px) { + .navbar-nav>li>ul { + max-height: calc(80vh - 50px); + overflow-y: auto; + } +} + /* Collapse the nav menu on displays less than 980px wide */ @media (max-width: 979px) { + #navbar { + max-height: calc(80vh - 50px); + overflow-y: auto; + } .navbar-header { float: none; } diff --git a/netbox/project-static/css/rack_elevation.css b/netbox/project-static/css/rack_elevation.css index 06120c223..cbb5015a5 100644 --- a/netbox/project-static/css/rack_elevation.css +++ b/netbox/project-static/css/rack_elevation.css @@ -56,3 +56,12 @@ text { .blocked:hover+.add-device { fill: none; } + +.unit { + margin: 0; + padding: 5px 0px; + + fill: #c0c0c0; + font-size: 10px; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; +} diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b1ba8a37c..4e1c9b0cc 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -220,19 +220,19 @@ $(document).ready(function() { } if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] } + results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; results[record.site.name + ":" + record.group.name].children.push(record); } else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] } + results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; results[record.group.name].children.push(record); } else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] } + results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; results[record.site.name].children.push(record); } else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] } + results['global'] = results['global'] || { text: 'Global', children: [] }; results['global'].children.push(record); } else { @@ -246,10 +246,9 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0]; results.unshift({ - id: null_option.value, - text: null_option.text + id: 'null', + text: 'None' }); } diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index def87b3a1..70abcfe29 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -15,15 +15,15 @@ router = routers.DefaultRouter() router.APIRootView = SecretsRootView # Field choices -router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') +router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') # Secrets -router.register(r'secret-roles', views.SecretRoleViewSet) -router.register(r'secrets', views.SecretViewSet) +router.register('secret-roles', views.SecretRoleViewSet) +router.register('secrets', views.SecretViewSet) # Miscellaneous -router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') -router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') +router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') +router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') app_name = 'secrets-api' urlpatterns = router.urls diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 873679775..367dc9bd0 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet): secret = self.get_object() - # Attempt to decrypt the secret if the master key is known - if self.master_key is not None: + # Attempt to decrypt the secret if the user is permitted and the master key is known + if secret.decryptable_by(request.user) and self.master_key is not None: secret.decrypt(self.master_key) serializer = self.get_serializer(secret) @@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet): if self.master_key is not None: secrets = [] for secret in page: - secret.decrypt(self.master_key) + # Enforce role permissions + if secret.decryptable_by(request.user): + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 2b5e059ca..79064e0dd 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,8 +8,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, - StaticSelect2Multiple, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -87,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + role = DynamicModelChoiceField( + queryset=SecretRole.objects.all(), + widget=APISelect( + api_url="/api/secrets/secret-roles/" + ) + ) tags = TagField( required=False ) @@ -96,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] - widgets = { - 'role': APISelect( - api_url="/api/secrets/secret-roles/" - ) - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -157,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=SecretRole.objects.all(), required=False, widget=APISelect( @@ -181,9 +182,10 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', + required=True, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index cabc340f9..df32ad7f2 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,7 +5,8 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import APITestCase +from users.models import Token +from utilities.testing import APITestCase, create_test_user from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -131,7 +132,15 @@ class SecretTest(APITestCase): def setUp(self): - super().setUp() + # Create a non-superuser test user + self.user = create_test_user('testuser', permissions=( + 'secrets.add_secret', + 'secrets.change_secret', + 'secrets.delete_secret', + 'secrets.view_secret', + )) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -144,11 +153,11 @@ class SecretTest(APITestCase): 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), } - self.plaintext = { - 'secret1': 'Secret #1 Plaintext', - 'secret2': 'Secret #2 Plaintext', - 'secret3': 'Secret #3 Plaintext', - } + self.plaintexts = ( + 'Secret #1 Plaintext', + 'Secret #2 Plaintext', + 'Secret #3 Plaintext', + ) site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -160,17 +169,17 @@ class SecretTest(APITestCase): self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') self.secret1 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] + device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0] ) self.secret1.encrypt(self.master_key) self.secret1.save() self.secret2 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] + device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1] ) self.secret2.encrypt(self.master_key) self.secret2.save() self.secret3 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] + device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2] ) self.secret3.encrypt(self.master_key) self.secret3.save() @@ -178,16 +187,32 @@ class SecretTest(APITestCase): def test_get_secret(self): url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.get(url, **self.header) - self.assertEqual(response.data['plaintext'], self.plaintext['secret1']) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) + self.assertIsNone(response.data['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['plaintext'], self.plaintexts[0]) def test_list_secrets(self): url = reverse('secrets-api:secret-list') - response = self.client.get(url, **self.header) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + for secret in response.data['results']: + self.assertIsNone(secret['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], 3) + for i, secret in enumerate(response.data['results']): + self.assertEqual(secret['plaintext'], self.plaintexts[i]) def test_create_secret(self): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 94f4cbd6a..96439a10d 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -4,18 +4,13 @@ from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY -class SecretRoleTestCase(StandardTestCases.Views): +class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = SecretRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -41,7 +36,7 @@ class SecretRoleTestCase(StandardTestCases.Views): ) -class SecretTestCase(StandardTestCases.Views): +class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Secret # Disable inapplicable tests diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 9d07dd63c..4ed08da7f 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -8,21 +8,21 @@ app_name = 'secrets' urlpatterns = [ # Secret roles - path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), - path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), - path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - path(r'secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - path(r'secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), + path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets - path(r'secrets/', views.SecretListView.as_view(), name='secret_list'), - path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), - path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), - path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), - path(r'secrets//', views.SecretView.as_view(), name='secret'), - path(r'secrets//edit/', views.secret_edit, name='secret_edit'), - path(r'secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), - path(r'secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path('secrets/', views.SecretListView.as_view(), name='secret_list'), + path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), + path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), + path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), + path('secrets//', views.SecretView.as_view(), name='secret'), + path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), + path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/templates/dcim/device_component_list.html b/netbox/templates/dcim/consoleport_list.html similarity index 56% rename from netbox/templates/dcim/device_component_list.html rename to netbox/templates/dcim/consoleport_list.html index 28322973e..0ed840820 100644 --- a/netbox/templates/dcim/device_component_list.html +++ b/netbox/templates/dcim/consoleport_list.html @@ -1,16 +1,14 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
{% export_button content_type %}
-

{% block title %}{{ table.Meta.model|model_name|capfirst }}s{% endblock %}

+

{% block title %}Console Ports{% endblock %}

- {% include 'responsive_table.html' %} - {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %}
{% include 'inc/search_panel.html' %} diff --git a/netbox/templates/dcim/consoleserverport_list.html b/netbox/templates/dcim/consoleserverport_list.html new file mode 100644 index 000000000..47a8676e3 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Console Server Ports{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index fa37f1ac5..5ede19d78 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -48,14 +48,30 @@ Add Components
{% endif %} @@ -333,12 +349,12 @@ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}