merge develop

This commit is contained in:
John Anderson 2020-02-13 16:00:07 -05:00
commit 9ead2635c5
58 changed files with 1620 additions and 1146 deletions

3
.github/stale.yml vendored
View File

@ -1,5 +1,8 @@
# Configuration for Stale (https://github.com/apps/stale) # 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 # Number of days of inactivity before an issue becomes stale
daysUntilStale: 14 daysUntilStale: 14

View File

@ -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: 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 * `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) * `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 ## Example

View File

@ -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) * TIMEOUT - Amount of time to wait for a connection (seconds)
* FROM_EMAIL - Sender address for emails sent by NetBox * 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 ## EXEMPT_VIEW_PERMISSIONS

View File

@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
* `PASSWORD` - PostgreSQL password * `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally) * `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) * `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: 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 ## 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 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. 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 ## SECRET_KEY

View File

@ -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. 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 complete source code must be published and freely accessible without registration.
* Its license must be conducive to inclusion in an open source project. * 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. * 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. * 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. * 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`. * 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.

View File

@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md) 1. [PostgreSQL database](1-postgresql.md)
2. [NetBox components](2-netbox.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) 4. [LDAP authentication](4-ldap.md) (optional)
# Upgrading # Upgrading

View File

@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui
```no-highlight ```no-highlight
# sudo systemctl restart netbox # sudo systemctl restart netbox
# sudo systemctl restart netbox-rqworker # sudo systemctl restart netbox-rq
``` ```
!!! note !!! note

View File

@ -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 ## 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 * [#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 * [#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 * [#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 * [#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 ## 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 * [#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 * [#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 * [#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 * [#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) # v2.7.4 (2020-02-04)

View File

@ -41,7 +41,6 @@ pages:
- Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md' - Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md' - Tags: 'additional-features/tags.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Webhooks: 'additional-features/webhooks.md' - Webhooks: 'additional-features/webhooks.md'
- Administration: - Administration:
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'

View File

@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, 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 .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -107,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -119,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
@ -164,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm):
# #
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 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() comments = CommentField()
tags = TagField( tags = TagField(
required=False required=False
@ -180,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'commit_rate': "Committed rate", 'commit_rate': "Committed rate",
} }
widgets = { widgets = {
'provider': APISelect(
api_url="/api/circuits/providers/"
),
'type': APISelect(
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(), 'status': StaticSelect2(),
'install_date': DatePicker(), 'install_date': DatePicker(),
} }
@ -235,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
type = forms.ModelChoiceField( type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/circuits/circuit-types/" api_url="/api/circuits/circuit-types/"
) )
) )
provider = forms.ModelChoiceField( provider = DynamicModelChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -255,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
initial='', initial='',
widget=StaticSelect2() widget=StaticSelect2()
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -290,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False, required=False,
label='Search' label='Search'
) )
type = FilterChoiceField( type = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/circuits/circuit-types/", api_url="/api/circuits/circuit-types/",
value_field="slug", value_field="slug",
) )
) )
provider = FilterChoiceField( provider = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/circuits/providers/", api_url="/api/circuits/providers/",
value_field="slug", value_field="slug",
@ -311,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
region = forms.ModelMultipleChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -323,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",

View File

@ -2,10 +2,10 @@ import datetime
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitType, Provider 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 model = Provider
@classmethod @classmethod
@ -46,14 +46,9 @@ class ProviderTestCase(StandardTestCases.Views):
} }
class CircuitTypeTestCase(StandardTestCases.Views): class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = CircuitType model = CircuitType
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -77,7 +72,7 @@ class CircuitTypeTestCase(StandardTestCases.Views):
) )
class CircuitTestCase(StandardTestCases.Views): class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit model = Circuit
@classmethod @classmethod

View File

@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False) status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True) 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) 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) tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True)
@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer() 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) tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True,
required=False required=False
) )
@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True,
required=False required=False
) )
@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
allow_blank=True,
required=False required=False
) )
@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
allow_blank=True,
required=False required=False
) )
power_port = PowerPortTemplateSerializer( power_port = PowerPortTemplateSerializer(
@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
) )
feed_leg = ChoiceField( feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
required=False, allow_blank=True,
allow_null=True required=False
) )
class Meta: class Meta:
@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack = NestedRackSerializer(required=False, allow_null=True) 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) status = ChoiceField(choices=DeviceStatusChoices, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True,
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
allow_blank=True,
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
allow_blank=True,
required=False required=False
) )
power_port = NestedPowerPortSerializer( power_port = NestedPowerPortSerializer(
@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
) )
feed_leg = ChoiceField( feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
required=False, allow_blank=True,
allow_null=True required=False
) )
cable = NestedCableSerializer( cable = NestedCableSerializer(
read_only=True read_only=True
@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
allow_blank=True,
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices, required=False) type = ChoiceField(choices=InterfaceTypeChoices, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True) 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) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer):
termination_a = serializers.SerializerMethodField(read_only=True) termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False) 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: class Meta:
model = Cable model = Cable

View File

@ -9,6 +9,8 @@ from .choices import InterfaceTypeChoices
RACK_U_HEIGHT_DEFAULT = 42 RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20

File diff suppressed because it is too large Load Diff

View File

@ -389,6 +389,10 @@ class RackElevationHelperMixin:
@staticmethod @staticmethod
def _draw_device_front(drawing, device, start, end, text): 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 color = device.device_role.color
link = drawing.add( link = drawing.add(
drawing.a( drawing.a(
@ -403,7 +407,7 @@ class RackElevationHelperMixin:
)) ))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color)) 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 @staticmethod
def _draw_device_rear(drawing, device, start, end, text): 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.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device')) 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 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: for unit in elevation:
# Loop through all units in the elevation # Loop through all units in the elevation
@ -447,9 +459,9 @@ class RackElevationHelperMixin:
# Setup drawing coordinates # Setup drawing coordinates
start_y = unit_cursor * unit_height start_y = unit_cursor * unit_height
end_y = unit_height * height end_y = unit_height * height
start_cordinates = (0, start_y) start_cordinates = (legend_width, start_y)
end_cordinates = (unit_width, end_y) end_cordinates = (legend_width + unit_width, end_y)
text_cordinates = (unit_width / 2, start_y + end_y / 2) text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
# Draw the device # Draw the device
if device and device.face == face: if device and device.face == face:
@ -471,7 +483,7 @@ class RackElevationHelperMixin:
unit_cursor += height unit_cursor += height
# Wrap the drawing with a border # 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 return drawing
@ -494,7 +506,8 @@ class RackElevationHelperMixin:
self, self,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, 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 Return an SVG of the rack elevation
@ -507,7 +520,7 @@ class RackElevationHelperMixin:
elevation = self.merge_elevations(face) elevation = self.merge_elevations(face)
reserved_units = self.get_reserved_units() 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): class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):

View File

@ -168,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel):
return PowerPort( return PowerPort(
device=device, device=device,
name=self.name, name=self.name,
type=self.type,
maximum_draw=self.maximum_draw, maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw allocated_draw=self.allocated_draw
) )
@ -232,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet( return PowerOutlet(
device=device, device=device,
name=self.name, name=self.name,
type=self.type,
power_port=power_port, power_port=power_port,
feed_leg=self.feed_leg feed_leg=self.feed_leg
) )

View File

@ -200,6 +200,11 @@ def get_component_template_actions(model_name):
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{{% endif %}} {{% endif %}}
{{% if perms.dcim.delete_{model_name} %}}
<a href="{{% url 'dcim:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{{% endif %}}
""".format(model_name=model_name).strip() """.format(model_name=model_name).strip()

View File

@ -11,7 +11,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import StandardTestCases from utilities.testing import ViewTestCases
def create_test_device(name): def create_test_device(name):
@ -27,14 +27,9 @@ def create_test_device(name):
return device return device
class RegionTestCase(StandardTestCases.Views): class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region model = Region
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -61,7 +56,7 @@ class RegionTestCase(StandardTestCases.Views):
) )
class SiteTestCase(StandardTestCases.Views): class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Site model = Site
@classmethod @classmethod
@ -118,14 +113,9 @@ class SiteTestCase(StandardTestCases.Views):
} }
class RackGroupTestCase(StandardTestCases.Views): class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackGroup model = RackGroup
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -152,14 +142,9 @@ class RackGroupTestCase(StandardTestCases.Views):
) )
class RackRoleTestCase(StandardTestCases.Views): class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackRole model = RackRole
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -184,7 +169,7 @@ class RackRoleTestCase(StandardTestCases.Views):
) )
class RackReservationTestCase(StandardTestCases.Views): class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = RackReservation model = RackReservation
# Disable inapplicable tests # Disable inapplicable tests
@ -226,7 +211,7 @@ class RackReservationTestCase(StandardTestCases.Views):
} }
class RackTestCase(StandardTestCases.Views): class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Rack model = Rack
@classmethod @classmethod
@ -302,14 +287,9 @@ class RackTestCase(StandardTestCases.Views):
} }
class ManufacturerTestCase(StandardTestCases.Views): class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer model = Manufacturer
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -332,7 +312,7 @@ class ManufacturerTestCase(StandardTestCases.Views):
) )
class DeviceTypeTestCase(StandardTestCases.Views): class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = DeviceType model = DeviceType
@classmethod @classmethod
@ -528,19 +508,9 @@ device-bays:
# DeviceType components # DeviceType components
# #
class ConsolePortTemplateTestCase(StandardTestCases.Views): class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsolePortTemplate 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 model = DeviceBayTemplate
# Disable inapplicable views # 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 test_bulk_edit_objects = None
def test_bulk_create_objects(self):
return self._test_bulk_create_objects(expected_count=3)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 model = DeviceRole
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -944,14 +841,9 @@ class DeviceRoleTestCase(StandardTestCases.Views):
) )
class PlatformTestCase(StandardTestCases.Views): class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Platform model = Platform
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -979,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views):
) )
class DeviceTestCase(StandardTestCases.Views): class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Device model = Device
@classmethod @classmethod
@ -1064,16 +956,9 @@ class DeviceTestCase(StandardTestCases.Views):
} }
class ConsolePortTestCase(StandardTestCases.Views): class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1113,16 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views):
) )
class ConsoleServerPortTestCase(StandardTestCases.Views): class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1163,16 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
) )
class PowerPortTestCase(StandardTestCases.Views): class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1218,16 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views):
) )
class PowerOutletTestCase(StandardTestCases.Views): class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1364,16 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views):
) )
class FrontPortTestCase(StandardTestCases.Views): class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1428,16 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views):
) )
class RearPortTestCase(StandardTestCases.Views): class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1479,19 +1326,12 @@ class RearPortTestCase(StandardTestCases.Views):
) )
class DeviceBayTestCase(StandardTestCases.Views): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay model = DeviceBay
# Disable inapplicable views # Disable inapplicable views
test_get_object = None
test_create_object = None
# TODO
test_bulk_edit_objects = None test_bulk_edit_objects = None
def test_bulk_create_objects(self):
return self._test_bulk_create_objects(expected_count=3)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device1 = create_test_device('Device 1') device1 = create_test_device('Device 1')
@ -1528,16 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views):
) )
class InventoryItemTestCase(StandardTestCases.Views): class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device = create_test_device('Device 1') device = create_test_device('Device 1')
@ -1589,7 +1422,7 @@ class InventoryItemTestCase(StandardTestCases.Views):
) )
class CableTestCase(StandardTestCases.Views): class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Cable model = Cable
# TODO: Creation URL needs termination context # TODO: Creation URL needs termination context
@ -1663,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views):
} }
class VirtualChassisTestCase(StandardTestCases.Views): class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis model = VirtualChassis
# Disable inapplicable tests # Disable inapplicable tests
@ -1717,7 +1550,7 @@ class VirtualChassisTestCase(StandardTestCases.Views):
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
class PowerPanelTestCase(StandardTestCases.Views): class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = PowerPanel model = PowerPanel
# Disable inapplicable tests # Disable inapplicable tests
@ -1758,7 +1591,7 @@ class PowerPanelTestCase(StandardTestCases.Views):
) )
class PowerFeedTestCase(StandardTestCases.Views): class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = PowerFeed model = PowerFeed
@classmethod @classmethod

View File

@ -95,48 +95,56 @@ urlpatterns = [
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), 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/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
# Console server port templates # Console server port templates
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), 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/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/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
# Power port templates # Power port templates
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), 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/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/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
# Power outlet templates # Power outlet templates
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), 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/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/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
# Interface templates # Interface templates
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), 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/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
# Front port templates # Front port templates
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), 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/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/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
# Rear port templates # Rear port templates
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), 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/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/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates # Device bay templates
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), 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/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/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Device roles # Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),

View File

@ -700,7 +700,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
# Device type components # Console port templates
# #
class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -717,6 +717,11 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.ConsolePortTemplateForm model_form = forms.ConsolePortTemplateForm
class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
model = ConsolePortTemplate
class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleporttemplate' permission_required = 'dcim.change_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all() queryset = ConsolePortTemplate.objects.all()
@ -730,6 +735,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
table = tables.ConsolePortTemplateTable table = tables.ConsolePortTemplateTable
#
# Console server port templates
#
class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate' permission_required = 'dcim.add_consoleserverporttemplate'
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
@ -744,6 +753,11 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
model_form = forms.ConsoleServerPortTemplateForm model_form = forms.ConsoleServerPortTemplateForm
class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
model = ConsoleServerPortTemplate
class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverporttemplate' permission_required = 'dcim.change_consoleserverporttemplate'
queryset = ConsoleServerPortTemplate.objects.all() queryset = ConsoleServerPortTemplate.objects.all()
@ -757,6 +771,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
table = tables.ConsoleServerPortTemplateTable table = tables.ConsoleServerPortTemplateTable
#
# Power port templates
#
class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate' permission_required = 'dcim.add_powerporttemplate'
model = PowerPortTemplate model = PowerPortTemplate
@ -771,6 +789,11 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerPortTemplateForm model_form = forms.PowerPortTemplateForm
class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
model = PowerPortTemplate
class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerporttemplate' permission_required = 'dcim.change_powerporttemplate'
queryset = PowerPortTemplate.objects.all() queryset = PowerPortTemplate.objects.all()
@ -784,6 +807,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
table = tables.PowerPortTemplateTable table = tables.PowerPortTemplateTable
#
# Power outlet templates
#
class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate' permission_required = 'dcim.add_poweroutlettemplate'
model = PowerOutletTemplate model = PowerOutletTemplate
@ -798,6 +825,11 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerOutletTemplateForm model_form = forms.PowerOutletTemplateForm
class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
model = PowerOutletTemplate
class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlettemplate' permission_required = 'dcim.change_poweroutlettemplate'
queryset = PowerOutletTemplate.objects.all() queryset = PowerOutletTemplate.objects.all()
@ -811,6 +843,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
table = tables.PowerOutletTemplateTable table = tables.PowerOutletTemplateTable
#
# Interface templates
#
class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate' permission_required = 'dcim.add_interfacetemplate'
model = InterfaceTemplate model = InterfaceTemplate
@ -825,6 +861,11 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.InterfaceTemplateForm model_form = forms.InterfaceTemplateForm
class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
model = InterfaceTemplate
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate' permission_required = 'dcim.change_interfacetemplate'
queryset = InterfaceTemplate.objects.all() queryset = InterfaceTemplate.objects.all()
@ -838,6 +879,10 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
table = tables.InterfaceTemplateTable table = tables.InterfaceTemplateTable
#
# Front port templates
#
class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_frontporttemplate' permission_required = 'dcim.add_frontporttemplate'
model = FrontPortTemplate model = FrontPortTemplate
@ -852,6 +897,11 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.FrontPortTemplateForm model_form = forms.FrontPortTemplateForm
class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_frontporttemplate'
model = FrontPortTemplate
class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_frontporttemplate' permission_required = 'dcim.change_frontporttemplate'
queryset = FrontPortTemplate.objects.all() queryset = FrontPortTemplate.objects.all()
@ -865,6 +915,10 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
table = tables.FrontPortTemplateTable table = tables.FrontPortTemplateTable
#
# Rear port templates
#
class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_rearporttemplate' permission_required = 'dcim.add_rearporttemplate'
model = RearPortTemplate model = RearPortTemplate
@ -879,6 +933,11 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.RearPortTemplateForm model_form = forms.RearPortTemplateForm
class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rearporttemplate'
model = RearPortTemplate
class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rearporttemplate' permission_required = 'dcim.change_rearporttemplate'
queryset = RearPortTemplate.objects.all() queryset = RearPortTemplate.objects.all()
@ -892,6 +951,10 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
table = tables.RearPortTemplateTable table = tables.RearPortTemplateTable
#
# Device bay templates
#
class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate' permission_required = 'dcim.add_devicebaytemplate'
model = DeviceBayTemplate model = DeviceBayTemplate
@ -906,6 +969,11 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.DeviceBayTemplateForm model_form = forms.DeviceBayTemplateForm
class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
model = DeviceBayTemplate
# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
# permission_required = 'dcim.change_devicebaytemplate' # permission_required = 'dcim.change_devicebaytemplate'
# queryset = DeviceBayTemplate.objects.all() # queryset = DeviceBayTemplate.objects.all()

View File

@ -1,14 +1,15 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2, CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
BOOLEAN_WITH_BLANK_CHOICES, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
@ -190,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
# #
class ConfigContextForm(BootstrapMixin, forms.ModelForm): 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(), queryset=Tag.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -204,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConfigContext model = ConfigContext
fields = [ fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
'clusters', 'tenant_groups', 'tenants', 'tags', 'data', '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): class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@ -265,72 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/regions/", api_url="/api/dcim/regions/",
value_field="slug", value_field="slug",
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
) )
) )
role = FilterChoiceField( role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/device-roles/", api_url="/api/dcim/device-roles/",
value_field="slug", value_field="slug",
) )
) )
platform = FilterChoiceField( platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/platforms/", api_url="/api/dcim/platforms/",
value_field="slug", value_field="slug",
) )
) )
cluster_group = FilterChoiceField( cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/", api_url="/api/virtualization/cluster-groups/",
value_field="slug", value_field="slug",
) )
) )
cluster_id = FilterChoiceField( cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False,
label='Cluster', label='Cluster',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/virtualization/clusters/", api_url="/api/virtualization/clusters/",
) )
) )
tenant_group = FilterChoiceField( tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/", api_url="/api/tenancy/tenant-groups/",
value_field="slug", value_field="slug",
) )
) )
tenant = FilterChoiceField( tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/tenancy/tenants/", api_url="/api/tenancy/tenants/",
value_field="slug", value_field="slug",
) )
) )
tag = FilterChoiceField( tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/extras/tags/", api_url="/api/extras/tags/",
value_field="slug", value_field="slug",
@ -387,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
) )
action = forms.ChoiceField( action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices), 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( user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'), queryset=User.objects.order_by('username'),
required=False required=False,
widget=StaticSelect2()
) )
changed_object_type = forms.ModelChoiceField( changed_object_type = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'), queryset=ContentType.objects.order_by('model'),

View File

@ -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."))

View File

@ -48,7 +48,7 @@ class ScriptVariable:
""" """
form_field = forms.CharField 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 # Initialize field attributes
if not hasattr(self, 'field_attrs'): if not hasattr(self, 'field_attrs'):
@ -59,6 +59,8 @@ class ScriptVariable:
self.field_attrs['help_text'] = description self.field_attrs['help_text'] = description
if default: if default:
self.field_attrs['initial'] = default self.field_attrs['initial'] = default
if widget:
self.field_attrs['widget'] = widget
self.field_attrs['required'] = required self.field_attrs['required'] = required
# Initialize the list of optional validators if none have already been defined # 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) form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput): 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 return form_field

View File

@ -7,10 +7,10 @@ from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, ObjectChange, Tag 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 model = Tag
# Disable inapplicable tests # Disable inapplicable tests
@ -38,7 +38,7 @@ class TagTestCase(StandardTestCases.Views):
} }
class ConfigContextTestCase(StandardTestCases.Views): class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ConfigContext model = ConfigContext
# Disable inapplicable tests # Disable inapplicable tests

View File

@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False) 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) interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True) nat_outside = NestedIPAddressSerializer(read_only=True)
@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer):
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(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( ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
serializer=NestedIPAddressSerializer, serializer=NestedIPAddressSerializer,

View File

@ -8,8 +8,8 @@ from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
TreeNodeMultipleChoiceFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .choices import * from .choices import *
@ -307,12 +307,12 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
to_field_name='rd', to_field_name='rd',
label='VRF (RD)', label='VRF (RD)',
) )
device = django_filters.CharFilter( device = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='name', field_name='name',
label='Device', label='Device (name)',
) )
device_id = django_filters.NumberFilter( device_id = MultiValueNumberFilter(
method='filter_device', method='filter_device',
field_name='pk', field_name='pk',
label='Device (ID)', label='Device (ID)',
@ -388,8 +388,10 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: try:
device = Device.objects.prefetch_related('device_type').get(**{name: value}) devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] 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) return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()

View File

@ -10,9 +10,10 @@ from extras.forms import (
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .constants import * from .constants import *
@ -75,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -148,6 +149,12 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
# #
class AggregateForm(BootstrapMixin, CustomFieldModelForm): class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all(),
widget=APISelect(
api_url="/api/ipam/rirs/"
)
)
tags = TagField( tags = TagField(
required=False required=False
) )
@ -162,9 +169,6 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
'rir': "Regional Internet Registry responsible for this prefix", 'rir': "Regional Internet Registry responsible for this prefix",
} }
widgets = { widgets = {
'rir': APISelect(
api_url="/api/ipam/rirs/"
),
'date_added': DatePicker(), 'date_added': DatePicker(),
} }
@ -189,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
queryset=Aggregate.objects.all(), queryset=Aggregate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
rir = forms.ModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
required=False, required=False,
label='RIR', label='RIR',
@ -226,9 +230,10 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='Address family', label='Address family',
widget=StaticSelect2() widget=StaticSelect2()
) )
rir = FilterChoiceField( rir = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False,
label='RIR', label='RIR',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/rirs/", api_url="/api/ipam/rirs/",
@ -268,10 +273,16 @@ class RoleCSVForm(forms.ModelForm):
# #
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 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(), queryset=Site.objects.all(),
required=False, required=False,
label='Site',
widget=APISelect( widget=APISelect(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
filter_for={ filter_for={
@ -283,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
) )
vlan_group = ChainedModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False, required=False,
label='VLAN group', label='VLAN group',
widget=APISelect( widget=APISelect(
@ -300,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
) )
vlan = ChainedModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False, required=False,
label='VLAN', label='VLAN',
widget=APISelect( widget=APISelect(
@ -313,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
display_field='display_name' display_field='display_name'
) )
) )
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/roles/"
)
)
tags = TagField(required=False) tags = TagField(required=False)
class Meta: class Meta:
@ -322,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'tags', 'tags',
] ]
widgets = { widgets = {
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
),
'status': StaticSelect2(), 'status': StaticSelect2(),
'role': APISelect(
api_url="/api/ipam/roles/"
)
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -439,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Prefix.objects.all(), queryset=Prefix.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
site = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/dcim/sites/" api_url="/api/dcim/sites/"
) )
) )
vrf = forms.ModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label='VRF', label='VRF',
@ -459,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
max_value=PREFIX_LENGTH_MAX, max_value=PREFIX_LENGTH_MAX,
required=False required=False
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -471,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
role = forms.ModelChoiceField( role = DynamicModelChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -525,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
label='Mask length', label='Mask length',
widget=StaticSelect2() widget=StaticSelect2()
) )
vrf_id = FilterChoiceField( vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False,
label='VRF', label='VRF',
null_label='-- Global --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vrfs/", api_url="/api/ipam/vrfs/",
null_option=True, null_option=True,
@ -539,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -551,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
null_option=True, null_option=True,
) )
) )
role = FilterChoiceField( role = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/roles/", api_url="/api/ipam/roles/",
value_field="slug", value_field="slug",
@ -594,7 +599,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False 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(), queryset=Site.objects.all(),
required=False, required=False,
label='Site', label='Site',
@ -606,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
} }
) )
) )
nat_rack = ChainedModelChoiceField( nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains=(
('site', 'nat_site'),
),
required=False, required=False,
label='Rack', label='Rack',
widget=APISelect( widget=APISelect(
@ -624,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
} }
) )
) )
nat_device = ChainedModelChoiceField( nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains=(
('site', 'nat_site'),
('rack', 'nat_rack'),
),
required=False, required=False,
label='Device', label='Device',
widget=APISelect( widget=APISelect(
@ -651,11 +657,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
} }
) )
) )
nat_inside = ChainedModelChoiceField( nat_inside = DynamicModelChoiceField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
chains=(
('interface__device', 'nat_device'),
),
required=False, required=False,
label='IP Address', label='IP Address',
widget=APISelect( widget=APISelect(
@ -680,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),
'role': StaticSelect2(), 'role': StaticSelect2(),
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
)
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -757,6 +757,14 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/"
)
)
class Meta: class Meta:
model = IPAddress model = IPAddress
@ -766,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),
'role': StaticSelect2(), 'role': StaticSelect2(),
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
)
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -904,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
vrf = forms.ModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label='VRF', label='VRF',
@ -917,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
max_value=IPADDRESS_MASK_LENGTH_MAX, max_value=IPADDRESS_MASK_LENGTH_MAX,
required=False required=False
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -950,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
class IPAddressAssignForm(BootstrapMixin, forms.Form): class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf_id = forms.ModelChoiceField( vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label='VRF', label='VRF',
@ -996,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
label='Mask length', label='Mask length',
widget=StaticSelect2() widget=StaticSelect2()
) )
vrf_id = FilterChoiceField( vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False,
label='VRF', label='VRF',
null_label='-- Global --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vrfs/", api_url="/api/ipam/vrfs/",
null_option=True, null_option=True,
@ -1030,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
# #
class VLANGroupForm(BootstrapMixin, forms.ModelForm): class VLANGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
)
slug = SlugField() slug = SlugField()
class Meta: class Meta:
@ -1037,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
fields = [ fields = [
'site', 'name', 'slug', 'site', 'name', 'slug',
] ]
widgets = {
'site': APISelect(
api_url="/api/dcim/sites/"
)
}
class VLANGroupCSVForm(forms.ModelForm): class VLANGroupCSVForm(forms.ModelForm):
@ -1065,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form): class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -1077,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- Global --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
@ -1094,7 +1101,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
# #
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1107,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
) )
group = ChainedModelChoiceField( group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False, required=False,
label='Group',
widget=APISelect( widget=APISelect(
api_url='/api/ipam/vlan-groups/', 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) tags = TagField(required=False)
class Meta: class Meta:
@ -1135,9 +1145,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),
'role': APISelect(
api_url="/api/ipam/roles/"
)
} }
@ -1212,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
site = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/dcim/sites/" api_url="/api/dcim/sites/"
) )
) )
group = forms.ModelChoiceField( group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/ipam/vlan-groups/" api_url="/api/ipam/vlan-groups/"
) )
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1238,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
role = forms.ModelChoiceField( role = DynamicModelChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1263,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -1276,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- Global --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
value_field="slug", value_field="slug",
null_option=True, null_option=True,
) )
) )
group_id = FilterChoiceField( group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group', label='VLAN group',
null_label='-- None --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vlan-groups/", api_url="/api/ipam/vlan-groups/",
null_option=True, null_option=True,
@ -1300,10 +1307,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
role = FilterChoiceField( role = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/roles/", api_url="/api/ipam/roles/",
value_field="slug", value_field="slug",

View File

@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase):
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# TODO: Test for multiple values
def test_device(self): def test_device(self):
device = Device.objects.first() devices = Device.objects.all()[:2]
params = {'device_id': device.pk} params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': device.name} params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine(self): def test_virtual_machine(self):
vms = VirtualMachine.objects.all()[:2] vms = VirtualMachine.objects.all()[:2]

View File

@ -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)

View File

@ -5,10 +5,10 @@ from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import * from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF 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 model = VRF
@classmethod @classmethod
@ -43,14 +43,9 @@ class VRFTestCase(StandardTestCases.Views):
} }
class RIRTestCase(StandardTestCases.Views): class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RIR model = RIR
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -74,7 +69,7 @@ class RIRTestCase(StandardTestCases.Views):
) )
class AggregateTestCase(StandardTestCases.Views): class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Aggregate model = Aggregate
@classmethod @classmethod
@ -115,14 +110,9 @@ class AggregateTestCase(StandardTestCases.Views):
} }
class RoleTestCase(StandardTestCases.Views): class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role model = Role
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -147,7 +137,7 @@ class RoleTestCase(StandardTestCases.Views):
) )
class PrefixTestCase(StandardTestCases.Views): class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Prefix model = Prefix
@classmethod @classmethod
@ -207,7 +197,7 @@ class PrefixTestCase(StandardTestCases.Views):
} }
class IPAddressTestCase(StandardTestCases.Views): class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress model = IPAddress
@classmethod @classmethod
@ -254,14 +244,9 @@ class IPAddressTestCase(StandardTestCases.Views):
} }
class VLANGroupTestCase(StandardTestCases.Views): class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = VLANGroup model = VLANGroup
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -287,7 +272,7 @@ class VLANGroupTestCase(StandardTestCases.Views):
) )
class VLANTestCase(StandardTestCases.Views): class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VLAN model = VLAN
@classmethod @classmethod
@ -346,7 +331,7 @@ class VLANTestCase(StandardTestCases.Views):
} }
class ServiceTestCase(StandardTestCases.Views): class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service model = Service
# Disable inapplicable tests # Disable inapplicable tests

View File

@ -10,7 +10,8 @@
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = [] 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 = { DATABASE = {
'NAME': 'netbox', # Database name 'NAME': 'netbox', # Database name
'USER': '', # PostgreSQL username 'USER': '', # PostgreSQL username
@ -27,6 +28,9 @@ REDIS = {
'webhooks': { 'webhooks': {
'HOST': 'localhost', 'HOST': 'localhost',
'PORT': 6379, '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': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300, 'DEFAULT_TIMEOUT': 300,
@ -35,6 +39,9 @@ REDIS = {
'caching': { 'caching': {
'HOST': 'localhost', 'HOST': 'localhost',
'PORT': 6379, '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': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300, 'DEFAULT_TIMEOUT': 300,

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.7.5-dev' VERSION = '2.7.6-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -170,18 +170,31 @@ if 'caching' not in REDIS:
WEBHOOKS_REDIS = REDIS.get('webhooks', {}) WEBHOOKS_REDIS = REDIS.get('webhooks', {})
WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) 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_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
CACHING_REDIS = REDIS.get('caching', {}) CACHING_REDIS = REDIS.get('caching', {})
CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost')
CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379)
CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', [])
CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) CACHING_REDIS_USING_SENTINEL = all([
CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) isinstance(CACHING_REDIS_SENTINELS, (list, tuple)),
CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) 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 # Caching
# #
if CACHING_REDIS_USING_SENTINEL:
if CACHING_REDIS_SSL: CACHEOPS_SENTINEL = {
REDIS_CACHE_CON_STRING = 'rediss://' 'locations': CACHING_REDIS_SENTINELS,
'service_name': CACHING_REDIS_SENTINEL_SERVICE,
'db': CACHING_REDIS_DATABASE,
}
else: 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: 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_PASSWORD)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING, REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST, CACHING_REDIS_HOST,
CACHING_REDIS_PORT, CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE CACHING_REDIS_DATABASE
) )
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
if not CACHE_TIMEOUT: if not CACHE_TIMEOUT:
CACHEOPS_ENABLED = False CACHEOPS_ENABLED = False
else: else:
CACHEOPS_ENABLED = True CACHEOPS_ENABLED = True
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
CACHEOPS_DEFAULTS = { CACHEOPS_DEFAULTS = {
'timeout': CACHE_TIMEOUT 'timeout': CACHE_TIMEOUT
} }
@ -534,6 +554,15 @@ RQ_QUEUES = {
'PASSWORD': WEBHOOKS_REDIS_PASSWORD, 'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT, 'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
'SSL': WEBHOOKS_REDIS_SSL, '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
},
} }
} }

View File

@ -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 */ /* Collapse the nav menu on displays less than 980px wide */
@media (max-width: 979px) { @media (max-width: 979px) {
#navbar {
max-height: calc(80vh - 50px);
overflow-y: auto;
}
.navbar-header { .navbar-header {
float: none; float: none;
} }

View File

@ -56,3 +56,12 @@ text {
.blocked:hover+.add-device { .blocked:hover+.add-device {
fill: none; fill: none;
} }
.unit {
margin: 0;
padding: 5px 0px;
fill: #c0c0c0;
font-size: 10px;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
}

View File

@ -220,19 +220,19 @@ $(document).ready(function() {
} }
if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { 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); results[record.site.name + ":" + record.group.name].children.push(record);
} }
else if( record.group !== undefined && record.group !== null ) { 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); results[record.group.name].children.push(record);
} }
else if( record.site !== undefined && record.site !== null ) { 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); results[record.site.name].children.push(record);
} }
else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { 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); results['global'].children.push(record);
} }
else { else {
@ -246,10 +246,9 @@ $(document).ready(function() {
// Handle the null option, but only add it once // Handle the null option, but only add it once
if (element.getAttribute('data-null-option') && data.previous === null) { if (element.getAttribute('data-null-option') && data.previous === null) {
var null_option = $(element).children()[0];
results.unshift({ results.unshift({
id: null_option.value, id: 'null',
text: null_option.text text: 'None'
}); });
} }

View File

@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet):
secret = self.get_object() secret = self.get_object()
# Attempt to decrypt the secret if the master key is known # Attempt to decrypt the secret if the user is permitted and the master key is known
if self.master_key is not None: if secret.decryptable_by(request.user) and self.master_key is not None:
secret.decrypt(self.master_key) secret.decrypt(self.master_key)
serializer = self.get_serializer(secret) serializer = self.get_serializer(secret)
@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet):
if self.master_key is not None: if self.master_key is not None:
secrets = [] secrets = []
for secret in page: 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) secrets.append(secret)
serializer = self.get_serializer(secrets, many=True) serializer = self.get_serializer(secrets, many=True)
else: else:

View File

@ -8,8 +8,8 @@ from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
) )
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
StaticSelect2Multiple, TagFilterField FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
) )
from .constants import * from .constants import *
from .models import Secret, SecretRole, UserKey from .models import Secret, SecretRole, UserKey
@ -87,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
label='Plaintext (verify)', label='Plaintext (verify)',
widget=forms.PasswordInput() widget=forms.PasswordInput()
) )
role = DynamicModelChoiceField(
queryset=SecretRole.objects.all(),
widget=APISelect(
api_url="/api/secrets/secret-roles/"
)
)
tags = TagField( tags = TagField(
required=False required=False
) )
@ -96,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
fields = [ fields = [
'role', 'name', 'plaintext', 'plaintext2', 'tags', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
] ]
widgets = {
'role': APISelect(
api_url="/api/secrets/secret-roles/"
)
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -157,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Secret.objects.all(), queryset=Secret.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
role = forms.ModelChoiceField( role = DynamicModelChoiceField(
queryset=SecretRole.objects.all(), queryset=SecretRole.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -181,9 +182,10 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
role = FilterChoiceField( role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(), queryset=SecretRole.objects.all(),
to_field_name='slug', to_field_name='slug',
required=True,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/secrets/secret-roles/", api_url="/api/secrets/secret-roles/",
value_field="slug", value_field="slug",

View File

@ -5,7 +5,8 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey 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 from .constants import PRIVATE_KEY, PUBLIC_KEY
@ -131,7 +132,15 @@ class SecretTest(APITestCase):
def setUp(self): 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 = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save() userkey.save()
@ -144,11 +153,11 @@ class SecretTest(APITestCase):
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
} }
self.plaintext = { self.plaintexts = (
'secret1': 'Secret #1 Plaintext', 'Secret #1 Plaintext',
'secret2': 'Secret #2 Plaintext', 'Secret #2 Plaintext',
'secret3': 'Secret #3 Plaintext', 'Secret #3 Plaintext',
} )
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-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.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.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
self.secret1 = Secret( 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.encrypt(self.master_key)
self.secret1.save() self.secret1.save()
self.secret2 = Secret( 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.encrypt(self.master_key)
self.secret2.save() self.secret2.save()
self.secret3 = Secret( 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.encrypt(self.master_key)
self.secret3.save() self.secret3.save()
@ -178,16 +187,32 @@ class SecretTest(APITestCase):
def test_get_secret(self): def test_get_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) 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): def test_list_secrets(self):
url = reverse('secrets-api:secret-list') 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) 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): def test_create_secret(self):

View File

@ -4,18 +4,13 @@ from django.urls import reverse
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey 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 from .constants import PRIVATE_KEY, PUBLIC_KEY
class SecretRoleTestCase(StandardTestCases.Views): class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = SecretRole model = SecretRole
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -41,7 +36,7 @@ class SecretRoleTestCase(StandardTestCases.Views):
) )
class SecretTestCase(StandardTestCases.Views): class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Secret model = Secret
# Disable inapplicable tests # Disable inapplicable tests

View File

@ -1,11 +1,5 @@
{% load helpers %} {% load helpers %}
<ul class="rack_legend">
{% for u in rack.units %}
<li>{{ u }}</li>
{% endfor %}
</ul>
<div class="rack_frame"> <div class="rack_frame">
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object> <object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>

View File

@ -127,23 +127,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
{% if perms.secrets.view_secret %}
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
{% else %}
<span class="badge pull-right"><i class="fa fa-lock"></i></span>
<h4 class="list-group-item-heading">Secrets</h4>
{% endif %}
<p class="list-group-item-text text-muted">Cryptographically secured secret data</p>
</div>
</div>
</div>
</div> </div>
<div class="col-sm-6 col-md-4"> <div class="col-sm-6 col-md-4">
<div class="panel panel-default"> <div class="panel panel-default">
@ -259,6 +242,23 @@
</div> </div>
</div> </div>
<div class="col-sm-6 col-md-4"> <div class="col-sm-6 col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
{% if perms.secrets.view_secret %}
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
{% else %}
<span class="badge pull-right"><i class="fa fa-lock"></i></span>
<h4 class="list-group-item-heading">Secrets</h4>
{% endif %}
<p class="list-group-item-text text-muted">Cryptographically secured secret data</p>
</div>
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Reports</strong> <strong>Reports</strong>

View File

@ -478,6 +478,11 @@
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li> <li class="dropdown-header">Miscellaneous</li>
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}> <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
{% if perms.extras.add_configcontext %}
<div class="buttons pull-right">
<a href="{% url 'extras:configcontext_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
</div>
{% endif %}
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a> <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
</li> </li>
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}> <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>

View File

@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load form_helpers %} {% load form_helpers %}
{% load secret_helpers %}
{% block content %} {% block content %}
<form action="." method="post" class="form form-horizontal"> <form action="." method="post" class="form form-horizontal">
@ -34,7 +35,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Secret Data</strong></div> <div class="panel-heading"><strong>Secret Data</strong></div>
<div class="panel-body"> <div class="panel-body">
{% if secret.pk %} {% if secret.pk and secret|decryptable_by:request.user %}
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Current Plaintext</label> <label class="col-md-3 control-label required">Current Plaintext</label>
<div class="col-md-7"> <div class="col-md-7">

View File

@ -2,11 +2,11 @@ from django import forms
from taggit.forms import TagField from taggit.forms import TagField
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
) )
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
FilterChoiceField, SlugField, TagFilterField DynamicModelMultipleChoiceField, SlugField, TagFilterField,
) )
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm):
class TenantForm(BootstrapMixin, CustomFieldModelForm): class TenantForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelect(
api_url="/api/tenancy/tenant-groups/"
)
)
comments = CommentField() comments = CommentField()
tags = TagField( tags = TagField(
required=False required=False
@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Tenant model = Tenant
fields = [ fields = (
'name', 'slug', 'group', 'description', 'comments', 'tags', 'name', 'slug', 'group', 'description', 'comments', 'tags',
] )
widgets = {
'group': APISelect(
api_url="/api/tenancy/tenant-groups/"
)
}
class TenantCSVForm(CustomFieldModelForm): class TenantCSVForm(CustomFieldModelForm):
@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
group = forms.ModelChoiceField( group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -105,10 +107,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False, required=False,
label='Search' label='Search'
) )
group = FilterChoiceField( group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/", api_url="/api/tenancy/tenant-groups/",
value_field="slug", value_field="slug",
@ -122,8 +124,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Form extensions # Form extensions
# #
class TenancyForm(ChainedFieldsMixin, forms.Form): class TenancyForm(forms.Form):
tenant_group = forms.ModelChoiceField( tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -136,11 +138,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
} }
) )
) )
tenant = ChainedModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
chains=(
('group', 'tenant_group'),
),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/tenancy/tenants/' api_url='/api/tenancy/tenants/'
@ -160,10 +159,10 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
class TenancyFilterForm(forms.Form): class TenancyFilterForm(forms.Form):
tenant_group = FilterChoiceField( tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/", api_url="/api/tenancy/tenant-groups/",
value_field="slug", value_field="slug",
@ -173,10 +172,10 @@ class TenancyFilterForm(forms.Form):
} }
) )
) )
tenant = FilterChoiceField( tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/tenancy/tenants/", api_url="/api/tenancy/tenants/",
value_field="slug", value_field="slug",

View File

@ -1,15 +1,10 @@
from tenancy.models import Tenant, TenantGroup 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 model = TenantGroup
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -32,7 +27,7 @@ class TenantGroupTestCase(StandardTestCases.Views):
) )
class TenantTestCase(StandardTestCases.Views): class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tenant model = Tenant
@classmethod @classmethod

View File

@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
class ChoiceField(Field): class ChoiceField(Field):
""" """
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. 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.choiceset = choices
self.allow_blank = allow_blank
self._choices = dict() self._choices = dict()
# Unpack grouped choices # Unpack grouped choices
@ -77,6 +81,15 @@ class ChoiceField(Field):
super().__init__(**kwargs) 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): def to_representation(self, obj):
if obj is '': if obj is '':
return None return None
@ -93,6 +106,10 @@ class ChoiceField(Field):
return data return data
def to_internal_value(self, 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 # Provide an explicit error message if the request is trying to write a dict or list
if isinstance(data, (dict, list)): if isinstance(data, (dict, list)):

View File

@ -8,7 +8,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.db.models import Count from django.db.models import Count
from mptt.forms import TreeNodeMultipleChoiceField from django.forms import BoundField
from .choices import unpack_grouped_choices from .choices import unpack_grouped_choices
from .constants import * from .constants import *
@ -211,7 +211,7 @@ class SelectWithPK(StaticSelect2):
option_template_name = 'widgets/select_option_with_pk.html' 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: Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
<option value="37" api-value="console-server-port">console server port</option> <option value="37" api-value="console-server-port">console server port</option>
@ -259,9 +259,6 @@ class APISelect(SelectWithDisabled):
name of the query param and the value if the query param's value. 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. :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__( def __init__(
self, self,
api_url, api_url,
@ -525,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
return value 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=<value>)
where <value> 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): class SlugField(forms.SlugField):
""" """
Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. 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) super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
class FilterChoiceIterator(forms.models.ModelChoiceIterator): class DynamicModelChoiceMixin:
field_modifier = ''
def __iter__(self): def get_bound_field(self, form, field_name):
# Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string) bound_field = BoundField(form, self, field_name)
if self.field.null_label is not None:
yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label) # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
queryset = self.queryset.all() # will be populated on-demand via the APISelect widget.
# Can't use iterator() when queryset uses prefetch_related() field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier)
if not queryset._prefetch_related_lookups: if bound_field.data:
queryset = queryset.iterator() self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)})
for obj in queryset: elif bound_field.initial:
yield self.choice(obj) 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): class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
iterator = FilterChoiceIterator """
Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs): rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
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):
pass pass
class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
pass """
A multiple-choice version of DynamicModelChoiceField.
"""
field_modifier = '__in'
class LaxURLField(forms.URLField): class LaxURLField(forms.URLField):
@ -675,46 +636,6 @@ class BootstrapMixin(forms.BaseForm):
field.widget.attrs['placeholder'] = field.label 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): class ReturnURLForm(forms.Form):
""" """
Provides a hidden return URL field to control where the user is directed after the form is submitted. Provides a hidden return URL field to control where the user is directed after the form is submitted.

View File

@ -68,7 +68,7 @@ def naturalize_interface(value, max_length=None):
if match.group('type') is not None: if match.group('type') is not None:
output.append(match.group('type')) 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'): for part_name in ('id', 'channel', 'vc'):
part = match.group(part_name) part = match.group(part_name)
if part is not None: if part is not None:

View File

@ -1,9 +0,0 @@
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %}<optgroup label="{{ group_name }}">{% endif %}
{% for option in group_choices %}
{% if option.attrs.selected or option.value == "null" %}{% include option.template_name with widget=option %}{% endif %}
{% endfor %}
{% if group_name %}</optgroup>{% endif %}
{% endfor %}
</select>

View File

@ -82,7 +82,7 @@ def render_yaml(value):
""" """
Render a dictionary as formatted YAML. Render a dictionary as formatted YAML.
""" """
return yaml.dump(dict(value)) return yaml.dump(json.loads(json.dumps(value)))
@register.filter() @register.filter()

View File

@ -57,6 +57,53 @@ class TestCase(_TestCase):
expected_status, response.status_code, getattr(response, 'data', 'No data') 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): def assertInstanceEqual(self, instance, data):
""" """
Compare a model instance to a dictionary, checking that its attribute values match those specified 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)} 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. We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them.
""" """
class GetObjectViewTestCase(ModelViewTestCase):
class Views(TestCase):
""" """
Stock TestCase suitable for testing all standard View functions: Retrieve a single instance.
- List objects
- View single object
- Create new object
- Modify existing object
- Delete existing object
- Import multiple new objects
""" """
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=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object(self): def test_get_object(self):
instance = self.model.objects.first() instance = self.model.objects.first()
@ -211,6 +164,12 @@ class StandardTestCases:
response = self.client.get(instance.get_absolute_url()) response = self.client.get(instance.get_absolute_url())
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
class CreateObjectViewTestCase(ModelViewTestCase):
"""
Create a single new instance.
"""
form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object(self): def test_create_object(self):
initial_count = self.model.objects.count() initial_count = self.model.objects.count()
@ -235,6 +194,12 @@ class StandardTestCases:
instance = self.model.objects.order_by('-pk').first() instance = self.model.objects.order_by('-pk').first()
self.assertInstanceEqual(instance, self.form_data) self.assertInstanceEqual(instance, self.form_data)
class EditObjectViewTestCase(ModelViewTestCase):
"""
Edit a single existing instance.
"""
form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object(self): def test_edit_object(self):
instance = self.model.objects.first() instance = self.model.objects.first()
@ -259,6 +224,10 @@ class StandardTestCases:
instance = self.model.objects.get(pk=instance.pk) instance = self.model.objects.get(pk=instance.pk)
self.assertInstanceEqual(instance, self.form_data) self.assertInstanceEqual(instance, self.form_data)
class DeleteObjectViewTestCase(ModelViewTestCase):
"""
Delete a single instance.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object(self): def test_delete_object(self):
instance = self.model.objects.first() instance = self.model.objects.first()
@ -283,6 +252,66 @@ class StandardTestCases:
with self.assertRaises(ObjectDoesNotExist): with self.assertRaises(ObjectDoesNotExist):
self.model.objects.get(pk=instance.pk) 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=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_import_objects(self): def test_import_objects(self):
initial_count = self.model.objects.count() 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) 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=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects(self): def test_bulk_edit_objects(self):
# Bulk edit the first three objects only # 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)): for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
self.assertInstanceEqual(instance, self.bulk_edit_data) self.assertInstanceEqual(instance, self.bulk_edit_data)
class BulkDeleteObjectsViewTestCase(ModelViewTestCase):
"""
Delete multiple instances.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects(self): def test_bulk_delete_objects(self):
pk_list = self.model.objects.values_list('pk', flat=True) pk_list = self.model.objects.values_list('pk', flat=True)
@ -366,31 +405,55 @@ class StandardTestCases:
# Check that all objects were deleted # Check that all objects were deleted
self.assertEqual(self.model.objects.count(), 0) self.assertEqual(self.model.objects.count(), 0)
# class PrimaryObjectViewTestCase(
# Optional view tests GetObjectViewTestCase,
# These methods will run only if the required data 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=[]) class OrganizationalObjectViewTestCase(
def _test_bulk_create_objects(self, expected_count): CreateObjectViewTestCase,
initial_count = self.model.objects.count() EditObjectViewTestCase,
request = { ListObjectsViewTestCase,
'path': self._get_url('add'), ImportObjectsViewTestCase,
'data': post_data(self.bulk_create_data), BulkDeleteObjectsViewTestCase,
'follow': False, # Do not follow 302 redirects ):
} """
TestCase suitable for all organizational objects
"""
maxDiff = None
# Attempt to make the request without required permissions class DeviceComponentTemplateViewTestCase(
with disable_warnings('django.request'): EditObjectViewTestCase,
self.assertHttpStatus(self.client.post(**request), 403) DeleteObjectViewTestCase,
BulkCreateObjectsViewTestCase,
BulkEditObjectsViewTestCase,
BulkDeleteObjectsViewTestCase,
):
"""
TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.)
"""
maxDiff = None
# Assign the required permission and submit again class DeviceComponentViewTestCase(
self.add_permissions( EditObjectViewTestCase,
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) DeleteObjectViewTestCase,
) ListObjectsViewTestCase,
response = self.client.post(**request) BulkCreateObjectsViewTestCase,
self.assertHttpStatus(response, 302) ImportObjectsViewTestCase,
BulkEditObjectsViewTestCase,
self.assertEqual(initial_count + expected_count, self.model.objects.count()) BulkDeleteObjectsViewTestCase,
for instance in self.model.objects.order_by('-pk')[:expected_count]: ):
self.assertInstanceEqual(instance, self.bulk_create_data) """
TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
"""
maxDiff = None

View File

@ -21,11 +21,13 @@ def post_data(data):
return ret 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. Create a User with the given permissions.
""" """
user = User.objects.create_user(username=username) user = User.objects.create_user(username=username)
if permissions is None:
permissions = ()
for perm_name in permissions: for perm_name in permissions:
app, codename = perm_name.split('.') app, codename = perm_name.split('.')
perm = Permission.objects.get(content_type__app_label=app, codename=codename) perm = Permission.objects.get(content_type__app_label=app, codename=codename)

View File

@ -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)

View File

@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) 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) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),

View File

@ -8,14 +8,20 @@ from utilities.choices import ChoiceSet
class VirtualMachineStatusChoices(ChoiceSet): class VirtualMachineStatusChoices(ChoiceSet):
STATUS_ACTIVE = 'active'
STATUS_OFFLINE = 'offline' STATUS_OFFLINE = 'offline'
STATUS_ACTIVE = 'active'
STATUS_PLANNED = 'planned'
STATUS_STAGED = 'staged' STATUS_STAGED = 'staged'
STATUS_FAILED = 'failed'
STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = ( CHOICES = (
(STATUS_ACTIVE, 'Active'),
(STATUS_OFFLINE, 'Offline'), (STATUS_OFFLINE, 'Offline'),
(STATUS_ACTIVE, 'Active'),
(STATUS_PLANNED, 'Planned'),
(STATUS_STAGED, 'Staged'), (STATUS_STAGED, 'Staged'),
(STATUS_FAILED, 'Failed'),
(STATUS_DECOMMISSIONING, 'Decommissioning'),
) )
LEGACY_MAP = { LEGACY_MAP = {

View File

@ -14,9 +14,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
StaticSelect2Multiple, TagFilterField,
) )
from .choices import * from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -77,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm):
# #
class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 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() comments = CommentField()
tags = TagField( tags = TagField(
required=False required=False
@ -84,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta: class Meta:
model = Cluster model = Cluster
fields = [ fields = (
'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', '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): class ClusterCSVForm(CustomFieldModelCSVForm):
@ -147,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
type = forms.ModelChoiceField( type = DynamicModelChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/virtualization/cluster-types/" api_url="/api/virtualization/cluster-types/"
) )
) )
group = forms.ModelChoiceField( group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url="/api/virtualization/cluster-groups/" api_url="/api/virtualization/cluster-groups/"
) )
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False required=False,
widget=APISelect(
api_url="/api/tenancy/tenants/"
)
) )
site = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -189,7 +200,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant' 'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant'
] ]
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
type = FilterChoiceField( type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -198,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
value_field='slug', value_field='slug',
) )
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -210,10 +221,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --',
required=False, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
@ -221,10 +231,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
null_option=True, null_option=True,
) )
) )
group = FilterChoiceField( group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --',
required=False, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/", api_url="/api/virtualization/cluster-groups/",
@ -235,8 +244,8 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
tag = TagFilterField(model) tag = TagFilterField(model)
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
region = forms.ModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -249,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
} }
) )
) )
site = ChainedModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
chains=(
('region', 'region'),
),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/dcim/sites/', api_url='/api/dcim/sites/',
@ -263,11 +269,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
} }
) )
) )
rack = ChainedModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/dcim/racks/', 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), queryset=Device.objects.filter(cluster__isnull=True),
chains=(
('site', 'site'),
('rack', 'rack'),
),
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/dcim/devices/', api_url='/api/dcim/devices/',
display_field='display_name', display_field='display_name',
@ -331,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
# #
class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
cluster_group = forms.ModelChoiceField( cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -344,15 +343,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
) )
cluster = ChainedModelChoiceField( cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
chains=(
('group', 'cluster_group'),
),
widget=APISelect( widget=APISelect(
api_url='/api/virtualization/clusters/' 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( tags = TagField(
required=False required=False
) )
@ -373,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
widgets = { widgets = {
"status": StaticSelect2(), "status": StaticSelect2(),
"role": APISelect(
api_url="/api/dcim/device-roles/",
additional_query_params={
"vm_role": "True"
}
),
'primary_ip4': StaticSelect2(), 'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(), 'primary_ip6': StaticSelect2(),
'platform': APISelect(
api_url='/api/dcim/platforms/'
)
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -493,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
initial='', initial='',
widget=StaticSelect2(), widget=StaticSelect2(),
) )
cluster = forms.ModelChoiceField( cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/virtualization/clusters/' api_url='/api/virtualization/clusters/'
) )
) )
role = forms.ModelChoiceField( role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter( queryset=DeviceRole.objects.filter(
vm_role=True vm_role=True
), ),
@ -512,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
} }
) )
) )
tenant = forms.ModelChoiceField( tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/tenancy/tenants/' api_url='/api/tenancy/tenants/'
) )
) )
platform = forms.ModelChoiceField( platform = DynamicModelChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -559,34 +562,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False, required=False,
label='Search' label='Search'
) )
cluster_group = FilterChoiceField( cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/virtualization/cluster-groups/', api_url='/api/virtualization/cluster-groups/',
value_field="slug", value_field="slug",
null_option=True, null_option=True,
) )
) )
cluster_type = FilterChoiceField( cluster_type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/virtualization/cluster-types/', api_url='/api/virtualization/cluster-types/',
value_field="slug", value_field="slug",
null_option=True, null_option=True,
) )
) )
cluster_id = FilterChoiceField( cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False,
label='Cluster', label='Cluster',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/virtualization/clusters/', api_url='/api/virtualization/clusters/',
) )
) )
region = FilterChoiceField( region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
@ -598,20 +602,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
} }
) )
) )
site = FilterChoiceField( site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/dcim/sites/', api_url='/api/dcim/sites/',
value_field="slug", value_field="slug",
null_option=True, null_option=True,
) )
) )
role = FilterChoiceField( role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.filter(vm_role=True), queryset=DeviceRole.objects.filter(vm_role=True),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/dcim/device-roles/', api_url='/api/dcim/device-roles/',
value_field="slug", value_field="slug",
@ -626,10 +630,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
platform = FilterChoiceField( platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
to_field_name='slug', to_field_name='slug',
null_label='-- None --', required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/dcim/platforms/', api_url='/api/dcim/platforms/',
value_field="slug", value_field="slug",
@ -648,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
# #
class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, forms.ModelForm):
untagged_vlan = forms.ModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -657,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
full=True full=True
) )
) )
tagged_vlans = forms.ModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
@ -774,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=StaticSelect2(), widget=StaticSelect2(),
) )
untagged_vlan = forms.ModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -783,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
full=True full=True
) )
) )
tagged_vlans = forms.ModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
@ -862,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
untagged_vlan = forms.ModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -871,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
full=True full=True
) )
) )
tagged_vlans = forms.ModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(

View File

@ -267,9 +267,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
] ]
STATUS_CLASS_MAP = { STATUS_CLASS_MAP = {
'active': 'success', VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning',
'offline': 'warning', VirtualMachineStatusChoices.STATUS_ACTIVE: 'success',
'staged': 'primary', VirtualMachineStatusChoices.STATUS_PLANNED: 'info',
VirtualMachineStatusChoices.STATUS_STAGED: 'primary',
VirtualMachineStatusChoices.STATUS_FAILED: 'danger',
VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning',
} }
class Meta: class Meta:

View File

@ -3,19 +3,14 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Interface, Platform, Site from dcim.models import DeviceRole, Interface, Platform, Site
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import StandardTestCases from utilities.testing import ViewTestCases
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class ClusterGroupTestCase(StandardTestCases.Views): class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = ClusterGroup model = ClusterGroup
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -38,14 +33,9 @@ class ClusterGroupTestCase(StandardTestCases.Views):
) )
class ClusterTypeTestCase(StandardTestCases.Views): class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = ClusterType model = ClusterType
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -68,7 +58,7 @@ class ClusterTypeTestCase(StandardTestCases.Views):
) )
class ClusterTestCase(StandardTestCases.Views): class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Cluster model = Cluster
@classmethod @classmethod
@ -124,7 +114,7 @@ class ClusterTestCase(StandardTestCases.Views):
} }
class VirtualMachineTestCase(StandardTestCases.Views): class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualMachine model = VirtualMachine
@classmethod @classmethod
@ -193,17 +183,16 @@ class VirtualMachineTestCase(StandardTestCases.Views):
} }
class InterfaceTestCase(StandardTestCases.Views): class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
):
model = Interface model = Interface
# Disable inapplicable tests # Disable inapplicable tests
test_list_objects = None test_list_objects = None
test_create_object = None
test_import_objects = 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): def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL # Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}' return 'virtualization:interface_{}'