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 44298fec3..04b3972ca 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,18 +1,37 @@ -# v2.7.5 (FUTURE) +# 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) 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/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/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/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 3c3ae8b2e..8f035ccbb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,8 +22,8 @@ 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, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, + 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 @@ -66,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, @@ -78,9 +78,10 @@ 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", @@ -89,7 +90,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -181,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): @@ -231,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() @@ -324,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( @@ -369,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, @@ -386,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): @@ -420,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, @@ -432,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", @@ -473,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 @@ -501,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(), @@ -610,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( @@ -620,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( @@ -640,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False, widget=APISelect( @@ -706,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, @@ -718,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", @@ -729,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 @@ -745,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", @@ -764,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/', @@ -841,7 +842,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -863,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, @@ -912,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' ) @@ -927,9 +935,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'tags', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'subdevice_role': StaticSelect2() } @@ -952,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( @@ -979,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", @@ -1054,7 +1060,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1097,7 +1103,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1140,7 +1146,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1213,7 +1219,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1282,7 +1288,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1345,7 +1351,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1439,7 +1445,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1488,7 +1494,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1659,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 ) @@ -1669,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(), } @@ -1701,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/", @@ -1710,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/', @@ -1730,7 +1737,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): disabled_indicator='device' ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -1741,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( @@ -1765,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/', @@ -1801,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(), } @@ -2084,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( @@ -2118,7 +2123,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), required=False, - initial='', widget=StaticSelect2() ) serial = forms.CharField( @@ -2143,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, @@ -2155,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", @@ -2167,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/", @@ -2179,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/", @@ -2206,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", @@ -2359,7 +2364,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2444,7 +2449,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2543,7 +2548,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2661,7 +2666,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2820,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 ) @@ -2880,7 +2886,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2930,7 +2936,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): tags = TagField( required=False ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -2939,7 +2945,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -3078,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( @@ -3087,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( @@ -3180,7 +3186,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3358,7 +3364,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3439,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, @@ -3455,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( @@ -3472,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( @@ -3575,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, @@ -3587,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, @@ -3598,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/', @@ -3617,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 ) ) @@ -3629,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, @@ -3643,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/', @@ -3658,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( @@ -3887,9 +3877,10 @@ 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", @@ -3899,9 +3890,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field='slug', @@ -3910,10 +3902,10 @@ 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, @@ -3937,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', @@ -3972,7 +3964,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4069,9 +4061,10 @@ 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", @@ -4080,7 +4073,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4091,9 +4084,10 @@ 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", @@ -4102,7 +4096,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4113,9 +4107,10 @@ 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", @@ -4124,7 +4119,7 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4139,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 ) @@ -4148,18 +4156,10 @@ 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 = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4168,7 +4168,7 @@ class InventoryItemCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -4223,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( @@ -4259,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, @@ -4271,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", @@ -4282,7 +4283,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4290,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", @@ -4387,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/", @@ -4400,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/', @@ -4417,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', @@ -4448,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, @@ -4460,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", @@ -4481,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", @@ -4499,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/', @@ -4515,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): @@ -4566,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, @@ -4578,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", @@ -4589,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, @@ -4605,7 +4598,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -4616,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 @@ -4628,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(), @@ -4732,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( @@ -4742,7 +4742,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } ) ) - rack = forms.ModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, widget=APISelect( @@ -4799,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, @@ -4811,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", @@ -4823,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/models/__init__.py b/netbox/dcim/models/__init__.py index c31f4c713..f291fc825 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -389,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( @@ -403,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): @@ -433,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 @@ -447,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: @@ -471,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 @@ -494,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 @@ -507,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): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index ab4a078cf..faa42b035 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -168,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 ) @@ -232,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 ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 473d465bd..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() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index f8282833c..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 @@ -528,19 +508,9 @@ device-bays: # DeviceType components # -class ConsolePortTemplateTestCase(StandardTestCases.Views): +class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsolePortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -573,19 +543,9 @@ class ConsolePortTemplateTestCase(StandardTestCases.Views): } -class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): +class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsoleServerPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -618,19 +578,9 @@ class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): } -class PowerPortTemplateTestCase(StandardTestCases.Views): +class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -669,19 +619,9 @@ class PowerPortTemplateTestCase(StandardTestCases.Views): } -class PowerOutletTemplateTestCase(StandardTestCases.Views): +class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerOutletTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -720,19 +660,9 @@ class PowerOutletTemplateTestCase(StandardTestCases.Views): } -class InterfaceTemplateTestCase(StandardTestCases.Views): +class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InterfaceTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -768,19 +698,9 @@ class InterfaceTemplateTestCase(StandardTestCases.Views): } -class FrontPortTemplateTestCase(StandardTestCases.Views): +class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = FrontPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -824,19 +744,9 @@ class FrontPortTemplateTestCase(StandardTestCases.Views): } -class RearPortTemplateTestCase(StandardTestCases.Views): +class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = RearPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -871,20 +781,12 @@ class RearPortTemplateTestCase(StandardTestCases.Views): } -class DeviceBayTemplateTestCase(StandardTestCases.Views): +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_delete_object = None - test_import_objects = None test_bulk_edit_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -911,14 +813,9 @@ class DeviceBayTemplateTestCase(StandardTestCases.Views): } -class DeviceRoleTestCase(StandardTestCases.Views): +class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -944,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): @@ -979,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views): ) -class DeviceTestCase(StandardTestCases.Views): +class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device @classmethod @@ -1064,16 +956,9 @@ class DeviceTestCase(StandardTestCases.Views): } -class ConsolePortTestCase(StandardTestCases.Views): +class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1113,16 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views): ) -class ConsoleServerPortTestCase(StandardTestCases.Views): +class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1163,16 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): ) -class PowerPortTestCase(StandardTestCases.Views): +class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1218,16 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views): ) -class PowerOutletTestCase(StandardTestCases.Views): +class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1280,15 +1144,12 @@ class PowerOutletTestCase(StandardTestCases.Views): ) -class InterfaceTestCase(StandardTestCases.Views): +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): model = Interface - # Disable inapplicable views - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1364,16 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views): ) -class FrontPortTestCase(StandardTestCases.Views): +class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1428,16 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views): ) -class RearPortTestCase(StandardTestCases.Views): +class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1479,19 +1326,12 @@ class RearPortTestCase(StandardTestCases.Views): ) -class DeviceBayTestCase(StandardTestCases.Views): +class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay # Disable inapplicable views - test_get_object = None - test_create_object = None - - # TODO test_bulk_edit_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device1 = create_test_device('Device 1') @@ -1528,16 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views): ) -class InventoryItemTestCase(StandardTestCases.Views): +class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1589,7 +1422,7 @@ class InventoryItemTestCase(StandardTestCases.Views): ) -class CableTestCase(StandardTestCases.Views): +class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cable # TODO: Creation URL needs termination context @@ -1663,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views): } -class VirtualChassisTestCase(StandardTestCases.Views): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis # Disable inapplicable tests @@ -1717,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 @@ -1758,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 07d86cc36..165ca9e02 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -95,48 +95,56 @@ urlpatterns = [ 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('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('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('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('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('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('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('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('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 824961b3e..ae59890a3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -700,7 +700,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Device type components +# Console port templates # class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -717,6 +717,11 @@ 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() @@ -730,6 +735,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.ConsolePortTemplateTable +# +# Console server port templates +# + class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' model = ConsoleServerPortTemplate @@ -744,6 +753,11 @@ 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() @@ -757,6 +771,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet table = tables.ConsoleServerPortTemplateTable +# +# Power port templates +# + class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' model = PowerPortTemplate @@ -771,6 +789,11 @@ 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() @@ -784,6 +807,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTemplateTable +# +# Power outlet templates +# + class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' model = PowerOutletTemplate @@ -798,6 +825,11 @@ 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() @@ -811,6 +843,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.PowerOutletTemplateTable +# +# Interface templates +# + class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' model = InterfaceTemplate @@ -825,6 +861,11 @@ 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() @@ -838,6 +879,10 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTemplateTable +# +# Front port templates +# + class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontporttemplate' model = FrontPortTemplate @@ -852,6 +897,11 @@ 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() @@ -865,6 +915,10 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.FrontPortTemplateTable +# +# Rear port templates +# + class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearporttemplate' model = RearPortTemplate @@ -879,6 +933,11 @@ 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() @@ -892,6 +951,10 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.RearPortTemplateTable +# +# Device bay templates +# + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' model = DeviceBayTemplate @@ -906,6 +969,11 @@ 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() diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 8c9113d39..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 * @@ -190,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, @@ -204,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): @@ -265,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", @@ -387,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/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/filters.py b/netbox/ipam/filters.py index f0b3275f0..c2dbd573b 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,8 +8,8 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, - TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, + NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -307,12 +307,12 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, 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)', @@ -388,8 +388,10 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, 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/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 aa90bdcbb..f66828f69 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.5-dev' +VERSION = '2.7.6-dev' # 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/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/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/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 1ab2e05ac..b0fcf4908 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,11 +1,5 @@ {% load helpers %} -
    - {% for u in rack.units %} -
  • {{ u }}
  • - {% endfor %} -
-
diff --git a/netbox/templates/home.html b/netbox/templates/home.html index be63b19c5..c4821d6c3 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -127,23 +127,6 @@
-
-
- Secrets -
-
-
- {% if perms.secrets.view_secret %} - {{ stats.secret_count }} -

Secrets

- {% else %} - -

Secrets

- {% endif %} -

Cryptographically secured secret data

-
-
-
@@ -259,6 +242,23 @@
+
+
+ Secrets +
+
+
+ {% if perms.secrets.view_secret %} + {{ stats.secret_count }} +

Secrets

+ {% else %} + +

Secrets

+ {% endif %} +

Cryptographically secured secret data

+
+
+
Reports diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index eeb520a57..55ac3e5c2 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -478,6 +478,11 @@
  • + {% if perms.extras.add_configcontext %} +
    + +
    + {% endif %} Config Contexts diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index be196aa57..875e53c5c 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load form_helpers %} +{% load secret_helpers %} {% block content %}
    @@ -34,7 +35,7 @@
    Secret Data
    - {% if secret.pk %} + {% if secret.pk and secret|decryptable_by:request.user %}
    diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b0468b37a..5b828b661 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from taggit.forms import TagField from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, - FilterChoiceField, SlugField, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Tenant - fields = [ + fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', - ] - widgets = { - 'group': APISelect( - api_url="/api/tenancy/tenant-groups/" - ) - } + ) class TenantCSVForm(CustomFieldModelForm): @@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput() ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( @@ -105,10 +107,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - group = FilterChoiceField( + 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", @@ -122,8 +124,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): # Form extensions # -class TenancyForm(ChainedFieldsMixin, forms.Form): - tenant_group = forms.ModelChoiceField( +class TenancyForm(forms.Form): + tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( @@ -136,11 +138,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): } ) ) - tenant = ChainedModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - chains=( - ('group', 'tenant_group'), - ), required=False, widget=APISelect( api_url='/api/tenancy/tenants/' @@ -160,10 +159,10 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): class TenancyFilterForm(forms.Form): - 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", @@ -173,10 +172,10 @@ class TenancyFilterForm(forms.Form): } ) ) - 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", diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index a44ca2932..27e2c1591 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,15 +1,10 @@ from tenancy.models import Tenant, TenantGroup -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class TenantGroupTestCase(StandardTestCases.Views): +class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = TenantGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -32,7 +27,7 @@ class TenantGroupTestCase(StandardTestCases.Views): ) -class TenantTestCase(StandardTestCases.Views): +class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tenant @classmethod diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 5ef4156aa..95de2a25d 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): class ChoiceField(Field): """ - Represent a ChoiceField as {'value': , 'label': }. + Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. + + :param choices: An iterable of choices in the form (value, key). + :param allow_blank: Allow blank values in addition to the listed choices. """ - def __init__(self, choices, **kwargs): + def __init__(self, choices, allow_blank=False, **kwargs): self.choiceset = choices + self.allow_blank = allow_blank self._choices = dict() # Unpack grouped choices @@ -77,6 +81,15 @@ class ChoiceField(Field): super().__init__(**kwargs) + def validate_empty_values(self, data): + # Convert null to an empty string unless allow_null == True + if data is None: + if self.allow_null: + return True, None + else: + data = '' + return super().validate_empty_values(data) + def to_representation(self, obj): if obj is '': return None @@ -93,6 +106,10 @@ class ChoiceField(Field): return data def to_internal_value(self, data): + if data is '': + if self.allow_blank: + return data + raise ValidationError("This field may not be blank.") # Provide an explicit error message if the request is trying to write a dict or list if isinstance(data, (dict, list)): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 355484673..c9a857ad0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -8,7 +8,7 @@ from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count -from mptt.forms import TreeNodeMultipleChoiceField +from django.forms import BoundField from .choices import unpack_grouped_choices from .constants import * @@ -211,7 +211,7 @@ class SelectWithPK(StaticSelect2): option_template_name = 'widgets/select_option_with_pk.html' -class ContentTypeSelect(forms.Select): +class ContentTypeSelect(StaticSelect2): """ Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: @@ -259,9 +259,6 @@ class APISelect(SelectWithDisabled): name of the query param and the value if the query param's value. :param null_option: If true, include the static null option in the selection list. """ - # Only preload the selected option(s); new options are dynamically displayed and added via the API - template_name = 'widgets/select_api.html' - def __init__( self, api_url, @@ -525,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField): return value -class ChainedModelChoiceField(forms.ModelChoiceField): - """ - A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary - mapping of model fields to peer fields within the form. For example: - - country1 = forms.ModelChoiceField(queryset=Country.objects.all()) - city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'} - - The queryset of the `city1` field will be modified as - - .filter(country=) - - where is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.) - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - -class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): - """ - See ChainedModelChoiceField - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - class SlugField(forms.SlugField): """ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. @@ -581,46 +550,38 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class FilterChoiceIterator(forms.models.ModelChoiceIterator): +class DynamicModelChoiceMixin: + field_modifier = '' - def __iter__(self): - # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string) - if self.field.null_label is not None: - yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label) - queryset = self.queryset.all() - # Can't use iterator() when queryset uses prefetch_related() - if not queryset._prefetch_related_lookups: - queryset = queryset.iterator() - for obj in queryset: - yield self.choice(obj) + def get_bound_field(self, form, field_name): + bound_field = BoundField(form, self, field_name) + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) + if bound_field.data: + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)}) + elif bound_field.initial: + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)}) + else: + self.queryset = self.queryset.none() + + return bound_field -class FilterChoiceFieldMixin(object): - iterator = FilterChoiceIterator - - def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs): - self.null_label = null_label - self.count_attr = count_attr - if 'required' not in kwargs: - kwargs['required'] = False - if 'widget' not in kwargs: - kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super().__init__(*args, **kwargs) - - def label_from_instance(self, obj): - label = super().label_from_instance(obj) - obj_count = getattr(obj, self.count_attr, None) - if obj_count is not None: - return '{} ({})'.format(label, obj_count) - return label - - -class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ pass -class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): - pass +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): + """ + A multiple-choice version of DynamicModelChoiceField. + """ + field_modifier = '__in' class LaxURLField(forms.URLField): @@ -675,46 +636,6 @@ class BootstrapMixin(forms.BaseForm): field.widget.attrs['placeholder'] = field.label -class ChainedFieldsMixin(forms.BaseForm): - """ - Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - for field_name, field in self.fields.items(): - - if isinstance(field, ChainedModelChoiceField): - - filters_dict = {} - for (db_field, parent_field) in field.chains: - if self.is_bound and parent_field in self.data and self.data[parent_field]: - filters_dict[db_field] = self.data[parent_field] or None - elif self.initial.get(parent_field): - filters_dict[db_field] = self.initial[parent_field] - elif self.fields[parent_field].widget.attrs.get('nullable'): - filters_dict[db_field] = None - else: - break - - # Limit field queryset by chained field values - if filters_dict: - field.queryset = field.queryset.filter(**filters_dict) - # Editing an existing instance; limit field to its current value - elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name): - obj = getattr(self.instance, field_name) - if obj is not None: - field.queryset = field.queryset.filter(pk=obj.pk) - else: - field.queryset = field.queryset.none() - # Creating a new instance with no bound data; nullify queryset - elif not self.data.get(field_name): - field.queryset = field.queryset.none() - # Creating a new instance with bound data; limit queryset to the specified value - else: - field.queryset = field.queryset.filter(pk=self.data.get(field_name)) - - class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted. diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index d459e6f6c..a560e776e 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -68,7 +68,7 @@ def naturalize_interface(value, max_length=None): if match.group('type') is not None: output.append(match.group('type')) - # Finally, append any remaining fields, left-padding to eight digits each. + # Finally, append any remaining fields, left-padding to six digits each. for part_name in ('id', 'channel', 'vc'): part = match.group(part_name) if part is not None: diff --git a/netbox/utilities/templates/widgets/select_api.html b/netbox/utilities/templates/widgets/select_api.html deleted file mode 100644 index d9516086b..000000000 --- a/netbox/utilities/templates/widgets/select_api.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 4278b3b95..ae679c91a 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -82,7 +82,7 @@ def render_yaml(value): """ Render a dictionary as formatted YAML. """ - return yaml.dump(dict(value)) + return yaml.dump(json.loads(json.dumps(value))) @register.filter() diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index b5e2e1bab..8d1b1a1be 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -57,6 +57,53 @@ class TestCase(_TestCase): expected_status, response.status_code, getattr(response, 'data', 'No data') )) + +class ModelViewTestCase(TestCase): + """ + Base TestCase for model views. Subclass to test individual views. + """ + model = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.model is None: + raise Exception("Test case requires model to be defined") + + def _get_base_url(self): + """ + Return the base format for a URL for the test's model. Override this to test for a model which belongs + to a different app (e.g. testing Interfaces within the virtualization app). + """ + return '{}:{}_{{}}'.format( + self.model._meta.app_label, + self.model._meta.model_name + ) + + def _get_url(self, action, instance=None): + """ + Return the URL name for a specific action. An instance must be specified for + get/edit/delete views. + """ + url_format = self._get_base_url() + + if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): + return reverse(url_format.format(action)) + + elif action in ('get', 'edit', 'delete'): + if instance is None: + raise Exception("Resolving {} URL requires specifying an instance".format(action)) + # Attempt to resolve using slug first + if hasattr(self.model, 'slug'): + try: + return reverse(url_format.format(action), kwargs={'slug': instance.slug}) + except NoReverseMatch: + pass + return reverse(url_format.format(action), kwargs={'pk': instance.pk}) + + else: + raise Exception("Invalid action for URL resolution: {}".format(action)) + def assertInstanceEqual(self, instance, data): """ Compare a model instance to a dictionary, checking that its attribute values match those specified @@ -94,108 +141,14 @@ class APITestCase(TestCase): self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} -class StandardTestCases: +class ViewTestCases: """ We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them. """ - - class Views(TestCase): + class GetObjectViewTestCase(ModelViewTestCase): """ - Stock TestCase suitable for testing all standard View functions: - - List objects - - View single object - - Create new object - - Modify existing object - - Delete existing object - - Import multiple new objects + Retrieve a single instance. """ - model = None - - # Data to be sent when creating/editing individual objects - form_data = {} - - # CSV lines used for bulk import of new objects - csv_data = () - - # Form data used when creating multiple objects - bulk_create_data = {} - - # Form data to be used when editing multiple objects at once - bulk_edit_data = {} - - maxDiff = None - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - if self.model is None: - raise Exception("Test case requires model to be defined") - - # - # URL functions - # - - def _get_base_url(self): - """ - Return the base format for a URL for the test's model. Override this to test for a model which belongs - to a different app (e.g. testing Interfaces within the virtualization app). - """ - return '{}:{}_{{}}'.format( - self.model._meta.app_label, - self.model._meta.model_name - ) - - def _get_url(self, action, instance=None): - """ - Return the URL name for a specific action. An instance must be specified for - get/edit/delete views. - """ - url_format = self._get_base_url() - - if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): - return reverse(url_format.format(action)) - - elif action in ('get', 'edit', 'delete'): - if instance is None: - raise Exception("Resolving {} URL requires specifying an instance".format(action)) - # Attempt to resolve using slug first - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - return reverse(url_format.format(action), kwargs={'pk': instance.pk}) - - else: - raise Exception("Invalid action for URL resolution: {}".format(action)) - - # - # Standard view tests - # These methods will run by default. To disable a test, nullify its method on the subclasses TestCase: - # - # test_list_objects = None - # - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) - - # Built-in CSV export - if hasattr(self.model, 'csv_headers'): - response = self.client.get('{}?export'.format(self._get_url('list'))) - self.assertHttpStatus(response, 200) - self.assertEqual(response.get('Content-Type'), 'text/csv') - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): instance = self.model.objects.first() @@ -211,6 +164,12 @@ class StandardTestCases: response = self.client.get(instance.get_absolute_url()) self.assertHttpStatus(response, 200) + class CreateObjectViewTestCase(ModelViewTestCase): + """ + Create a single new instance. + """ + form_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object(self): initial_count = self.model.objects.count() @@ -235,6 +194,12 @@ class StandardTestCases: instance = self.model.objects.order_by('-pk').first() self.assertInstanceEqual(instance, self.form_data) + class EditObjectViewTestCase(ModelViewTestCase): + """ + Edit a single existing instance. + """ + form_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object(self): instance = self.model.objects.first() @@ -259,6 +224,10 @@ class StandardTestCases: instance = self.model.objects.get(pk=instance.pk) self.assertInstanceEqual(instance, self.form_data) + class DeleteObjectViewTestCase(ModelViewTestCase): + """ + Delete a single instance. + """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object(self): instance = self.model.objects.first() @@ -283,6 +252,66 @@ class StandardTestCases: with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) + class ListObjectsViewTestCase(ModelViewTestCase): + """ + Retrieve multiple instances. + """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(self._get_url('list')), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + + # Built-in CSV export + if hasattr(self.model, 'csv_headers'): + response = self.client.get('{}?export'.format(self._get_url('list'))) + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv') + + class BulkCreateObjectsViewTestCase(ModelViewTestCase): + """ + Create multiple instances using a single form. Expects the creation of three new instances by default. + """ + bulk_create_count = 3 + bulk_create_data = {} + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_create_objects(self): + initial_count = self.model.objects.count() + request = { + 'path': self._get_url('add'), + 'data': post_data(self.bulk_create_data), + 'follow': False, # Do not follow 302 redirects + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) + for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertInstanceEqual(instance, self.bulk_create_data) + + class ImportObjectsViewTestCase(ModelViewTestCase): + """ + Create multiple instances from imported data. + """ + csv_data = () + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_import_objects(self): initial_count = self.model.objects.count() @@ -307,6 +336,12 @@ class StandardTestCases: self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + class BulkEditObjectsViewTestCase(ModelViewTestCase): + """ + Edit multiple instances. + """ + bulk_edit_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects(self): # Bulk edit the first three objects only @@ -338,6 +373,10 @@ class StandardTestCases: for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) + class BulkDeleteObjectsViewTestCase(ModelViewTestCase): + """ + Delete multiple instances. + """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects(self): pk_list = self.model.objects.values_list('pk', flat=True) @@ -366,31 +405,55 @@ class StandardTestCases: # Check that all objects were deleted self.assertEqual(self.model.objects.count(), 0) - # - # Optional view tests - # These methods will run only if the required data - # + class PrimaryObjectViewTestCase( + GetObjectViewTestCase, + CreateObjectViewTestCase, + EditObjectViewTestCase, + DeleteObjectViewTestCase, + ListObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing all standard View functions for primary objects + """ + maxDiff = None - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def _test_bulk_create_objects(self, expected_count): - initial_count = self.model.objects.count() - request = { - 'path': self._get_url('add'), - 'data': post_data(self.bulk_create_data), - 'follow': False, # Do not follow 302 redirects - } + class OrganizationalObjectViewTestCase( + CreateObjectViewTestCase, + EditObjectViewTestCase, + ListObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for all organizational objects + """ + maxDiff = None - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) + class DeviceComponentTemplateViewTestCase( + EditObjectViewTestCase, + DeleteObjectViewTestCase, + BulkCreateObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.) + """ + maxDiff = None - # Assign the required permission and submit again - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - - self.assertEqual(initial_count + expected_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:expected_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + class DeviceComponentViewTestCase( + EditObjectViewTestCase, + DeleteObjectViewTestCase, + ListObjectsViewTestCase, + BulkCreateObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.) + """ + maxDiff = None diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 469b21111..38ec6e196 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -21,11 +21,13 @@ def post_data(data): return ret -def create_test_user(username='testuser', permissions=list()): +def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. """ user = User.objects.create_user(username=username) + if permissions is None: + permissions = () for perm_name in permissions: app, codename = perm_name.split('.') perm = Permission.objects.get(content_type__app_label=app, codename=codename) diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py new file mode 100644 index 000000000..a875c688c --- /dev/null +++ b/netbox/utilities/tests/test_ordering.py @@ -0,0 +1,43 @@ +from django.test import TestCase + +from utilities.ordering import naturalize, naturalize_interface + + +class NaturalizationTestCase(TestCase): + """ + Validate the operation of the functions which generate values suitable for natural ordering. + """ + def test_naturalize(self): + + data = ( + # Original, naturalized + ('abc', 'abc'), + ('123', '00000123'), + ('abc123', 'abc00000123'), + ('123abc', '00000123abc'), + ('123abc456', '00000123abc00000456'), + ('abc123def', 'abc00000123def'), + ('abc123def456', 'abc00000123def00000456'), + ) + + for origin, naturalized in data: + self.assertEqual(naturalize(origin), naturalized) + + def test_naturalize_interface(self): + + data = ( + # Original, naturalized + ('Gi', '9999999999999999Gi000000000000000000'), + ('Gi1', '9999999999999999Gi000001000000000000'), + ('Gi1/2', '0001999999999999Gi000002000000000000'), + ('Gi1/2/3', '0001000299999999Gi000003000000000000'), + ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'), + ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'), + ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'), + ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'), + ('Gi1:2', '9999999999999999Gi000001000002000000'), + ('Gi1:2.3', '9999999999999999Gi000001000002000003'), + ) + + for origin, naturalized in data: + self.assertEqual(naturalize_interface(origin), naturalized) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 8725cbee1..a294cdb6f 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) - 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(), diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 3c4a17c7b..1dae88e1d 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -8,14 +8,20 @@ from utilities.choices import ChoiceSet class VirtualMachineStatusChoices(ChoiceSet): - STATUS_ACTIVE = 'active' STATUS_OFFLINE = 'offline' + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' STATUS_STAGED = 'staged' + STATUS_FAILED = 'failed' + STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_ACTIVE, 'Active'), (STATUS_OFFLINE, 'Offline'), + (STATUS_ACTIVE, 'Active'), + (STATUS_PLANNED, 'Planned'), (STATUS_STAGED, 'Staged'), + (STATUS_FAILED, 'Failed'), + (STATUS_DECOMMISSIONING, 'Decommissioning'), ) LEGACY_MAP = { diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 1560a683f..12393d400 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -14,9 +14,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -77,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm): # class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + type = DynamicModelChoiceField( + queryset=ClusterType.objects.all(), + widget=APISelect( + api_url="/api/virtualization/cluster-types/" + ) + ) + group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-groups/" + ) + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -84,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster - fields = [ + fields = ( 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', - ] - widgets = { - 'type': APISelect( - api_url="/api/virtualization/cluster-types/" - ), - 'group': APISelect( - api_url="/api/virtualization/cluster-groups/" - ), - 'site': APISelect( - api_url="/api/dcim/sites/" - ), - } + ) class ClusterCSVForm(CustomFieldModelCSVForm): @@ -147,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-types/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -189,7 +200,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm 'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant' ] q = forms.CharField(required=False, label='Search') - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', required=False, @@ -198,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm value_field='slug', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -210,10 +221,9 @@ class ClusterFilterForm(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/", @@ -221,10 +231,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm null_option=True, ) ) - group = FilterChoiceField( + group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", @@ -235,8 +244,8 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm tag = TagFilterField(model) -class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - region = forms.ModelChoiceField( +class ClusterAddDevicesForm(BootstrapMixin, forms.Form): + region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, widget=APISelect( @@ -249,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), - chains=( - ('region', 'region'), - ), required=False, widget=APISelect( api_url='/api/dcim/sites/', @@ -263,11 +269,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -279,12 +282,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - devices = ChainedModelMultipleChoiceField( + devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), widget=APISelectMultiple( api_url='/api/dcim/devices/', display_field='display_name', @@ -331,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): # class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - cluster_group = forms.ModelChoiceField( + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -344,15 +343,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), widget=APISelect( api_url='/api/virtualization/clusters/' ) ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url="/api/dcim/device-roles/", + additional_query_params={ + "vm_role": "True" + } + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/platforms/' + ) + ) tags = TagField( required=False ) @@ -373,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { "status": StaticSelect2(), - "role": APISelect( - api_url="/api/dcim/device-roles/", - additional_query_params={ - "vm_role": "True" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), - 'platform': APISelect( - api_url='/api/dcim/platforms/' - ) } def __init__(self, *args, **kwargs): @@ -493,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB initial='', widget=StaticSelect2(), ) - cluster = forms.ModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, widget=APISelect( api_url='/api/virtualization/clusters/' ) ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True ), @@ -512,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB } ) ) - 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( @@ -559,34 +562,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, label='Search' ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) ) - cluster_type = FilterChoiceField( + cluster_type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url='/api/virtualization/clusters/', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -598,20 +602,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil } ) ) - 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=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', - null_label='-- None --', + required=False, widget=APISelectMultiple( api_url='/api/dcim/device-roles/', value_field="slug", @@ -626,10 +630,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, widget=StaticSelect2Multiple() ) - 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", @@ -648,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # class InterfaceForm(BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -657,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -774,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2(), ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -783,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -862,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -871,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3ec5ccf8e..13b181137 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -267,9 +267,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ] STATUS_CLASS_MAP = { - 'active': 'success', - 'offline': 'warning', - 'staged': 'primary', + VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning', + VirtualMachineStatusChoices.STATUS_ACTIVE: 'success', + VirtualMachineStatusChoices.STATUS_PLANNED: 'info', + VirtualMachineStatusChoices.STATUS_STAGED: 'primary', + VirtualMachineStatusChoices.STATUS_FAILED: 'danger', + VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning', } class Meta: diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 6cedf9803..639908977 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -3,19 +3,14 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Interface, Platform, Site from ipam.models import VLAN -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine -class ClusterGroupTestCase(StandardTestCases.Views): +class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -38,14 +33,9 @@ class ClusterGroupTestCase(StandardTestCases.Views): ) -class ClusterTypeTestCase(StandardTestCases.Views): +class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterType - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -68,7 +58,7 @@ class ClusterTypeTestCase(StandardTestCases.Views): ) -class ClusterTestCase(StandardTestCases.Views): +class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cluster @classmethod @@ -124,7 +114,7 @@ class ClusterTestCase(StandardTestCases.Views): } -class VirtualMachineTestCase(StandardTestCases.Views): +class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualMachine @classmethod @@ -193,17 +183,16 @@ class VirtualMachineTestCase(StandardTestCases.Views): } -class InterfaceTestCase(StandardTestCases.Views): +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): model = Interface # Disable inapplicable tests test_list_objects = None - test_create_object = None test_import_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL return 'virtualization:interface_{}'