Merge branch 'feature' into 3979-wireless

This commit is contained in:
jeremystretch 2021-10-21 13:19:52 -04:00
commit 3a3ed8bf64
203 changed files with 3444 additions and 1014 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.7 placeholder: v3.0.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.0.7 placeholder: v3.0.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -0,0 +1,5 @@
# Contacts
{!models/tenancy/contact.md!}
{!models/tenancy/contactgroup.md!}
{!models/tenancy/contactrole.md!}

View File

@ -1 +0,0 @@
{!models/extras/customlink.md!}

View File

@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | | Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | | Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: |
| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Component Template | :material-check: | :material-check: | :material-check: | | | | | | Component Template | :material-check: | :material-check: | :material-check: | | | | |

View File

@ -2,4 +2,5 @@
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) Each location must have a name that is unique within its parent site and location, if any.

View File

@ -1,6 +1,6 @@
# Racks # Racks
The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.

View File

@ -1,3 +1,5 @@
# Regions # Regions
Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned.
Each region must have a name that is unique within its parent region, if any.

View File

@ -1,3 +1,5 @@
# Site Groups # Site Groups
Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups. Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups.
Each site group must have a name that is unique within its parent group, if any.

View File

@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav
```no-highlight ```no-highlight
GET /api/dcim/devices/?tag=monitored&tag=deprecated GET /api/dcim/devices/?tag=monitored&tag=deprecated
``` ```
!!! note
Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label.

View File

@ -0,0 +1,31 @@
# Contacts
A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups.
Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available:
* Title
* Phone
* Email
* Address
## Contact Assignment
Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object.
The following models support the assignment of contacts:
* circuits.Circuit
* circuits.Provider
* dcim.Device
* dcim.Location
* dcim.Manufacturer
* dcim.PowerPanel
* dcim.Rack
* dcim.Region
* dcim.Site
* dcim.SiteGroup
* tenancy.Tenant
* virtualization.Cluster
* virtualization.ClusterGroup
* virtualization.VirtualMachine

View File

@ -0,0 +1,3 @@
# Contact Groups
Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated.

View File

@ -0,0 +1,3 @@
# Contact Roles
Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts.

View File

@ -1,5 +1,5 @@
# Clusters # Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.

View File

@ -1,6 +1,27 @@
# NetBox v3.0 # NetBox v3.0
## v3.0.8 (FUTURE) ## v3.0.9 (FUTURE)
---
## v3.0.8 (2021-10-20)
### Enhancements
* [#7551](https://github.com/netbox-community/netbox/issues/7551) - Add UI field to filter interfaces by kind
* [#7561](https://github.com/netbox-community/netbox/issues/7561) - Add a utilization column to the IP ranges table
### Bug Fixes
* [#7300](https://github.com/netbox-community/netbox/issues/7300) - Fix incorrect Device LLDP interface row coloring
* [#7495](https://github.com/netbox-community/netbox/issues/7495) - Fix navigation UI issue that caused improper element overlap
* [#7529](https://github.com/netbox-community/netbox/issues/7529) - Restore horizontal scrolling for tables in narrow viewports
* [#7534](https://github.com/netbox-community/netbox/issues/7534) - Avoid exception when utilizing "create and add another" twice in succession
* [#7544](https://github.com/netbox-community/netbox/issues/7544) - Fix multi-value filtering of custom field objects
* [#7545](https://github.com/netbox-community/netbox/issues/7545) - Fix incorrect display of update/delete events for webhooks
* [#7550](https://github.com/netbox-community/netbox/issues/7550) - Fix rendering of UTF8-encoded data in change records
* [#7556](https://github.com/netbox-community/netbox/issues/7556) - Fix display of version when new release is available
* [#7584](https://github.com/netbox-community/netbox/issues/7584) - Fix alignment of object identifier under object view
--- ---

View File

@ -3,13 +3,65 @@
!!! warning "PostgreSQL 10 Required" !!! warning "PostgreSQL 10 Required"
NetBox v3.1 requires PostgreSQL 10 or later. NetBox v3.1 requires PostgreSQL 10 or later.
### Breaking Changes
* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
#### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344))
A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management.
When assigning a contact to an object, the user must select a predefined role (e.g. "billing" or "technical") and may optionally indicate a priority relative to other contacts associated with the object. There is no limit on how many contacts can be assigned to an object, nor on how many objects to which a contact can be assigned.
####
### Enhancements ### Enhancements
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
* [#7354](https://github.com/netbox-community/netbox/issues/7354) - Relax uniqueness constraints on region, site group, and location names
* [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views
### Other Changes ### Other Changes
* [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10 * [#7318](https://github.com/netbox-community/netbox/issues/7318) - Raise minimum required PostgreSQL version from 9.6 to 10
### REST API Changes
* Added the following endpoints for contacts:
* `/api/tenancy/contact-assignments/`
* `/api/tenancy/contact-groups/`
* `/api/tenancy/contact-roles/`
* `/api/tenancy/contacts/`
* Added `tags` field to the following models:
* circuits.CircuitType
* dcim.DeviceRole
* dcim.Location
* dcim.Manufacturer
* dcim.Platform
* dcim.RackRole
* dcim.Region
* dcim.SiteGroup
* ipam.RIR
* ipam.Role
* ipam.VLANGroup
* tenancy.ContactGroup
* tenancy.ContactRole
* tenancy.TenantGroup
* virtualization.ClusterGroup
* virtualization.ClusterType
* dcim.Cable
* Added `tenant` field
* dcim.Device
* Added `airflow` field
* dcim.DeviceType
* Added `airflow` field
* dcim.Interface
* Added `wwn` field
* dcim.Location
* Added `tenant` field

View File

@ -63,10 +63,11 @@ nav:
- Wireless: 'core-functionality/wireless.md' - Wireless: 'core-functionality/wireless.md'
- Power Tracking: 'core-functionality/power.md' - Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md' - Tenancy: 'core-functionality/tenancy.md'
- Contacts: 'core-functionality/contacts.md'
- Customization: - Customization:
- Custom Fields: 'customization/custom-fields.md' - Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md' - Custom Validation: 'customization/custom-validation.md'
- Custom Links: 'customization/custom-links.md' - Custom Links: 'models/extras/customlink.md'
- Export Templates: 'customization/export-templates.md' - Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md' - Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md' - Reports: 'customization/reports.md'

View File

@ -5,9 +5,7 @@ from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField from netbox.api import ChoiceField
from netbox.api.serializers import ( from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import * from .nested_serializers import *
@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
# Circuits # Circuits
# #
class CircuitTypeSerializer(OrganizationalModelSerializer): class CircuitTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'circuit_count', 'circuit_count',
] ]

View File

@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
# #
class CircuitTypeViewSet(CustomFieldModelViewSet): class CircuitTypeViewSet(CustomFieldModelViewSet):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.prefetch_related('tags').annotate(
circuit_count=count_related(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer

View File

@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
] ]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput

View File

@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'name', 'slug', 'description', 'name', 'slug', 'description', 'tags',
] ]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.8 on 2021-10-21 14:50
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0062_clear_secrets_changelog'),
('circuits', '0002_squashed_0029'),
]
operations = [
migrations.AddField(
model_name='circuittype',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('circuits', '0002_squashed_0029'), ('circuits', '0003_extend_tag_support'),
] ]
operations = [ operations = [

View File

@ -62,6 +62,11 @@ class Provider(PrimaryModel):
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
clone_fields = [ clone_fields = [
@ -123,7 +128,7 @@ class ProviderNetwork(PrimaryModel):
return reverse('circuits:providernetwork', args=[self.pk]) return reverse('circuits:providernetwork', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class CircuitType(OrganizationalModel): class CircuitType(OrganizationalModel):
""" """
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@ -203,6 +208,11 @@ class Circuit(PrimaryModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )

View File

@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn(
url_name='circuits:circuittype_list'
)
circuit_count = tables.Column( circuit_count = tables.Column(
verbose_name='Circuits' verbose_name='Circuits'
) )
@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CircuitType model = CircuitType
fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')

View File

@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Circuit Type X', 'name': 'Circuit Type X',
'slug': 'circuit-type-x', 'slug': 'circuit-type-x',
'description': 'A new circuit type', 'description': 'A new circuit type',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -2,7 +2,6 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from timezone_field.rest_framework import TimeZoneSerializerField from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import * from dcim.choices import *
@ -12,8 +11,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer
from ipam.models import VLAN from ipam.models import VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
WritableNestedSerializer,
) )
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
@ -83,27 +81,27 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer):
class RegionSerializer(NestedGroupModelSerializer): class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True) parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True) site_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Region model = Region
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
class SiteGroupSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedSiteGroupSerializer(required=False, allow_null=True) parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True) site_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'site_count', '_depth', 'last_updated', 'site_count', '_depth',
] ]
@ -146,20 +144,20 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta: class Meta:
model = Location model = Location
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields', 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count', 'device_count', '_depth', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
] ]
class RackRoleSerializer(OrganizationalModelSerializer): class RackRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'rack_count', 'last_updated', 'rack_count',
] ]
@ -171,6 +169,8 @@ class RackSerializer(PrimaryModelSerializer):
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, allow_blank=True, required=False) type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID',
default=None)
width = ChoiceField(choices=RackWidthChoices, required=False) width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
@ -183,23 +183,6 @@ class RackSerializer(PrimaryModelSerializer):
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
] ]
# Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
# prevents facility_id from being interpreted as a required field.
validators = [
UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name'))
]
def validate(self, data):
# Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta.
if data.get('facility_id', None):
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id'))
validator(data, self)
# Enforce model validation
super().validate(data)
return data
class RackUnitSerializer(serializers.Serializer): class RackUnitSerializer(serializers.Serializer):
@ -271,7 +254,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
# Device types # Device types
# #
class ManufacturerSerializer(OrganizationalModelSerializer): class ManufacturerSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True) devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True)
@ -280,7 +263,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count', 'devicetype_count', 'inventoryitem_count', 'platform_count',
] ]
@ -428,7 +411,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
# Devices # Devices
# #
class DeviceRoleSerializer(OrganizationalModelSerializer): class DeviceRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
@ -436,12 +419,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
'last_updated', 'device_count', 'virtualmachine_count', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
class PlatformSerializer(OrganizationalModelSerializer): class PlatformSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
@ -451,7 +434,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
@ -459,12 +442,13 @@ class DeviceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer() device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
location = NestedLocationSerializer(required=False, allow_null=True, default=None) location = NestedLocationSerializer(required=False, allow_null=True, default=None)
rack = NestedRackSerializer(required=False, allow_null=True) rack = NestedRackSerializer(required=False, allow_null=True, default=None)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
status = ChoiceField(choices=DeviceStatusChoices, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
@ -472,7 +456,8 @@ class DeviceSerializer(PrimaryModelSerializer):
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
class Meta: class Meta:
model = Device model = Device
@ -482,19 +467,6 @@ class DeviceSerializer(PrimaryModelSerializer):
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
validators = []
def validate(self, data):
# Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
if data.get('rack') and data.get('position') and data.get('face'):
validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
validator(data, self)
# Enforce model validation
super().validate(data)
return data
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
def get_parent_device(self, obj): def get_parent_device(self, obj):
@ -733,7 +705,6 @@ class DeviceBaySerializer(PrimaryModelSerializer):
class InventoryItemSerializer(PrimaryModelSerializer): class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)
@ -761,14 +732,15 @@ class CableSerializer(PrimaryModelSerializer):
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=LinkStatusChoices, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'custom_fields', 'tags', 'custom_fields',
] ]
def _get_termination(self, obj, side): def _get_termination(self, obj, side):

View File

@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet):
'region', 'region',
'site_count', 'site_count',
cumulative=True cumulative=True
) ).prefetch_related('tags')
serializer_class = serializers.RegionSerializer serializer_class = serializers.RegionSerializer
filterset_class = filtersets.RegionFilterSet filterset_class = filtersets.RegionFilterSet
@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
'group', 'group',
'site_count', 'site_count',
cumulative=True cumulative=True
) ).prefetch_related('tags')
serializer_class = serializers.SiteGroupSerializer serializer_class = serializers.SiteGroupSerializer
filterset_class = filtersets.SiteGroupFilterSet filterset_class = filtersets.SiteGroupFilterSet
@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet):
'location', 'location',
'rack_count', 'rack_count',
cumulative=True cumulative=True
).prefetch_related('site') ).prefetch_related('site', 'tags')
serializer_class = serializers.LocationSerializer serializer_class = serializers.LocationSerializer
filterset_class = filtersets.LocationFilterSet filterset_class = filtersets.LocationFilterSet
@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet):
# #
class RackRoleViewSet(CustomFieldModelViewSet): class RackRoleViewSet(CustomFieldModelViewSet):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.prefetch_related('tags').annotate(
rack_count=count_related(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet):
# #
class ManufacturerViewSet(CustomFieldModelViewSet): class ManufacturerViewSet(CustomFieldModelViewSet):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.prefetch_related('tags').annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'), devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet):
# #
class DeviceRoleViewSet(CustomFieldModelViewSet): class DeviceRoleViewSet(CustomFieldModelViewSet):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role') virtualmachine_count=count_related(VirtualMachine, 'role')
) )
@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
# #
class PlatformViewSet(CustomFieldModelViewSet): class PlatformViewSet(CustomFieldModelViewSet):
queryset = Platform.objects.annotate( queryset = Platform.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'platform'), device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform') virtualmachine_count=count_related(VirtualMachine, 'platform')
) )

View File

@ -704,6 +704,18 @@ class PowerOutletFeedLegChoices(ChoiceSet):
# Interfaces # Interfaces
# #
class InterfaceKindChoices(ChoiceSet):
KIND_PHYSICAL = 'physical'
KIND_VIRTUAL = 'virtual'
KIND_WIRELESS = 'wireless'
CHOICES = (
(KIND_PHYSICAL, 'Physical'),
(KIND_VIRTUAL, 'Virtual'),
(KIND_WIRELESS, 'Wireless'),
)
class InterfaceTypeChoices(ChoiceSet): class InterfaceTypeChoices(ChoiceSet):
# Virtual # Virtual

View File

@ -1199,7 +1199,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct() return queryset.filter(qs_filter).distinct()
class CableFilterSet(PrimaryModelFilterSet): class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -1240,14 +1240,6 @@ class CableFilterSet(PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__site__slug' field_name='device__site__slug'
) )
tenant_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant_id'
)
tenant = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant__slug'
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:

View File

@ -51,7 +51,7 @@ __all__ = (
) )
class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'description'] nullable_fields = ['parent', 'description']
class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
] ]
class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'tenant', 'description'] nullable_fields = ['parent', 'tenant', 'description']
class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = [] nullable_fields = []
class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['airflow'] nullable_fields = ['airflow']
class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description'] nullable_fields = ['color', 'description']
class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -468,6 +468,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect(), widget=StaticSelect(),
initial='' initial=''
) )
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
label = forms.CharField( label = forms.CharField(
max_length=100, max_length=100,
required=False required=False
@ -488,7 +492,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'type', 'status', 'label', 'color', 'length', 'type', 'status', 'tenant', 'label', 'color', 'length',
] ]
def clean(self): def clean(self):

View File

@ -828,6 +828,12 @@ class CableCSVForm(CustomFieldModelCSVForm):
required=False, required=False,
help_text='Physical medium classification' help_text='Physical medium classification'
) )
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
length_unit = CSVChoiceField( length_unit = CSVChoiceField(
choices=CableLengthUnitChoices, choices=CableLengthUnitChoices,
required=False, required=False,
@ -838,7 +844,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
model = Cable model = Cable
fields = [ fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'label', 'color', 'length', 'length_unit', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
] ]
help_texts = { help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'), 'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),

View File

@ -2,6 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelForm from extras.forms import CustomFieldModelForm
from extras.models import Tag from extras.models import Tag
from tenancy.forms import TenancyForm
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = ( __all__ = (
@ -17,7 +18,7 @@ __all__ = (
) )
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
""" """
Base form for connecting a Cable to a Device component Base form for connecting a Cable to a Device component
""" """
@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
model = Cable model = Cable
fields = [ fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
] ]
widgets = { widgets = {
'status': StaticSelect, 'status': StaticSelect,
@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
) )
class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField( termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
label='Provider', label='Provider',
@ -219,7 +221,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
model = Cable model = Cable
fields = [ fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):
@ -227,7 +230,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
return getattr(self.cleaned_data['termination_b_id'], 'pk', None) return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField( termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
label='Region', label='Region',
@ -280,8 +283,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
'color', 'length', 'length_unit', 'tags', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):

View File

@ -7,7 +7,6 @@ from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@ -692,13 +691,13 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
tag = TagFilterField(model) tag = TagFilterField(model)
class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Cable model = Cable
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['site_id', 'rack_id', 'device_id'], ['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'], ['type', 'status', 'color'],
['tenant_id'], ['tenant_group_id', 'tenant_id'],
] ]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
@ -720,12 +719,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Site'), label=_('Site'),
fetch_trigger='open' fetch_trigger='open'
) )
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant'),
fetch_trigger='open'
)
rack_id = DynamicModelMultipleChoiceField( rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
@ -973,10 +966,15 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface model = Interface
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
['rf_role', 'rf_channel', 'rf_channel_width'], ['rf_role', 'rf_channel', 'rf_channel_width'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
] ]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
required=False,
widget=StaticSelectMultiple()
)
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
required=False, required=False,

View File

@ -71,11 +71,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Region model = Region
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'parent', 'name', 'slug', 'description', 'tags',
) )
@ -85,11 +89,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = SiteGroup model = SiteGroup
fields = ( fields = (
'parent', 'name', 'slug', 'description', 'parent', 'name', 'slug', 'description', 'tags',
) )
@ -188,15 +196,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Location model = Location
fields = ( fields = (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
) )
fieldsets = ( fieldsets = (
('Location', ( ('Location', (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
)), )),
('Tenancy', ('tenant_group', 'tenant')), ('Tenancy', ('tenant_group', 'tenant')),
) )
@ -204,11 +216,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RackRoleForm(BootstrapMixin, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'name', 'slug', 'color', 'description', 'name', 'slug', 'color', 'description', 'tags',
] ]
@ -344,11 +360,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
'name', 'slug', 'description', 'name', 'slug', 'description', 'tags',
] ]
@ -393,11 +413,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = [ fields = [
'name', 'slug', 'color', 'vm_role', 'description', 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
] ]
@ -409,11 +433,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField( slug = SlugField(
max_length=64 max_length=64
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
] ]
widgets = { widgets = {
'napalm_args': SmallTextarea(), 'napalm_args': SmallTextarea(),
@ -602,7 +630,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')] self.fields['position'].widget.choices = [(position, f'U{position}')]
class CableForm(BootstrapMixin, CustomFieldModelForm): class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -611,7 +639,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect, 'status': StaticSelect,

View File

@ -15,4 +15,9 @@ class Migration(migrations.Migration):
name='tenant', name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'),
), ),
migrations.AddField(
model_name='cable',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'),
),
] ]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0135_location_tenant'), ('dcim', '0135_tenancy_extensions'),
] ]
operations = [ operations = [

View File

@ -0,0 +1,45 @@
# Generated by Django 3.2.8 on 2021-10-19 17:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0136_device_airflow'),
]
operations = [
migrations.AlterField(
model_name='region',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='region',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AlterField(
model_name='sitegroup',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='sitegroup',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AlterUniqueTogether(
name='location',
unique_together={('site', 'parent', 'name'), ('site', 'parent', 'slug')},
),
migrations.AlterUniqueTogether(
name='region',
unique_together={('parent', 'slug'), ('parent', 'name')},
),
migrations.AlterUniqueTogether(
name='sitegroup',
unique_together={('parent', 'slug'), ('parent', 'name')},
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 3.2.8 on 2021-10-21 14:50
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0062_clear_secrets_changelog'),
('dcim', '0137_relax_uniqueness_constraints'),
]
operations = [
migrations.AddField(
model_name='devicerole',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='location',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='manufacturer',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='platform',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='rackrole',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='region',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='sitegroup',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0136_device_airflow'), ('dcim', '0138_extend_tag_support'),
] ]
operations = [ operations = [

View File

@ -5,7 +5,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0137_rename_cable_peer'), ('dcim', '0139_rename_cable_peer'),
('wireless', '0001_wireless'), ('wireless', '0001_wireless'),
] ]

View File

@ -67,6 +67,13 @@ class Cable(PrimaryModel):
choices=LinkStatusChoices, choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED default=LinkStatusChoices.STATUS_CONNECTED
) )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='cables',
blank=True,
null=True
)
label = models.CharField( label = models.CharField(
max_length=100, max_length=100,
blank=True blank=True

View File

@ -36,7 +36,7 @@ __all__ = (
# Device Types # Device Types
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel): class Manufacturer(OrganizationalModel):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel):
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
@ -346,7 +351,7 @@ class DeviceType(PrimaryModel):
# Devices # Devices
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel): class DeviceRole(OrganizationalModel):
""" """
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@ -386,7 +391,7 @@ class DeviceRole(OrganizationalModel):
return reverse('dcim:devicerole', args=[self.pk]) return reverse('dcim:devicerole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Platform(OrganizationalModel): class Platform(OrganizationalModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )

View File

@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel):
name = models.CharField( name = models.CharField(
max_length=100 max_length=100
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )

View File

@ -35,7 +35,7 @@ __all__ = (
# Racks # Racks
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel): class RackRole(OrganizationalModel):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
@ -175,12 +175,17 @@ class Rack(PrimaryModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
# Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
to='ipam.VLANGroup', to='ipam.VLANGroup',
content_type_field='scope_type', content_type_field='scope_type',
object_id_field='scope_id', object_id_field='scope_id',
related_query_name='rack' related_query_name='rack'
) )
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )

View File

@ -25,7 +25,7 @@ __all__ = (
# Regions # Regions
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel): class Region(NestedGroupModel):
""" """
A region represents a geographic collection of sites. For example, you might create regions representing countries, A region represents a geographic collection of sites. For example, you might create regions representing countries,
@ -41,23 +41,32 @@ class Region(NestedGroupModel):
db_index=True db_index=True
) )
name = models.CharField( name = models.CharField(
max_length=100, max_length=100
unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100, max_length=100
unique=True
) )
description = models.CharField( description = models.CharField(
max_length=200, max_length=200,
blank=True blank=True
) )
# Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
to='ipam.VLANGroup', to='ipam.VLANGroup',
content_type_field='scope_type', content_type_field='scope_type',
object_id_field='scope_id', object_id_field='scope_id',
related_query_name='region' related_query_name='region'
) )
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
class Meta:
unique_together = (
('parent', 'name'),
('parent', 'slug'),
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk]) return reverse('dcim:region', args=[self.pk])
@ -73,7 +82,7 @@ class Region(NestedGroupModel):
# Site groups # Site groups
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel): class SiteGroup(NestedGroupModel):
""" """
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@ -89,23 +98,32 @@ class SiteGroup(NestedGroupModel):
db_index=True db_index=True
) )
name = models.CharField( name = models.CharField(
max_length=100, max_length=100
unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100, max_length=100
unique=True
) )
description = models.CharField( description = models.CharField(
max_length=200, max_length=200,
blank=True blank=True
) )
# Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
to='ipam.VLANGroup', to='ipam.VLANGroup',
content_type_field='scope_type', content_type_field='scope_type',
object_id_field='scope_id', object_id_field='scope_id',
related_query_name='site_group' related_query_name='site_group'
) )
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
class Meta:
unique_together = (
('parent', 'name'),
('parent', 'slug'),
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk]) return reverse('dcim:sitegroup', args=[self.pk])
@ -221,12 +239,17 @@ class Site(PrimaryModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
# Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
to='ipam.VLANGroup', to='ipam.VLANGroup',
content_type_field='scope_type', content_type_field='scope_type',
object_id_field='scope_id', object_id_field='scope_id',
related_query_name='site' related_query_name='site'
) )
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -255,7 +278,7 @@ class Site(PrimaryModel):
# Locations # Locations
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel): class Location(NestedGroupModel):
""" """
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
@ -291,12 +314,17 @@ class Location(NestedGroupModel):
max_length=200, max_length=200,
blank=True blank=True
) )
# Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
to='ipam.VLANGroup', to='ipam.VLANGroup',
content_type_field='scope_type', content_type_field='scope_type',
object_id_field='scope_id', object_id_field='scope_id',
related_query_name='location' related_query_name='location'
) )
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -305,10 +333,10 @@ class Location(NestedGroupModel):
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
unique_together = [ unique_together = ([
['site', 'name'], ('site', 'parent', 'name'),
['site', 'slug'], ('site', 'parent', 'slug'),
] ])
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk]) return reverse('dcim:location', args=[self.pk])

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Cable from dcim.models import Cable
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
@ -45,6 +46,7 @@ class CableTable(BaseTable):
verbose_name='Termination B' verbose_name='Termination B'
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = TenantColumn()
length = TemplateColumn( length = TemplateColumn(
template_code=CABLE_LENGTH, template_code=CABLE_LENGTH,
order_by='_abs_length' order_by='_abs_length'
@ -58,7 +60,7 @@ class CableTable(BaseTable):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'color', 'length', 'tags', 'status', 'type', 'tenant', 'color', 'length', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -80,11 +80,16 @@ class DeviceRoleTable(BaseTable):
) )
color = ColorColumn() color = ColorColumn()
vm_role = BooleanColumn() vm_role = BooleanColumn()
tags = TagColumn(
url_name='dcim:devicerole_list'
)
actions = ButtonsColumn(DeviceRole) actions = ButtonsColumn(DeviceRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceRole model = DeviceRole
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') fields = (
'pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@ -107,13 +112,16 @@ class PlatformTable(BaseTable):
url_params={'platform_id': 'pk'}, url_params={'platform_id': 'pk'},
verbose_name='VMs' verbose_name='VMs'
) )
tags = TagColumn(
url_name='dcim:platform_list'
)
actions = ButtonsColumn(Platform) actions = ButtonsColumn(Platform)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Platform model = Platform
fields = ( fields = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'actions', 'description', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',

View File

@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms' verbose_name='Platforms'
) )
slug = tables.Column() slug = tables.Column()
tags = TagColumn(
url_name='dcim:manufacturer_list'
)
actions = ButtonsColumn(Manufacturer) actions = ButtonsColumn(Manufacturer)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Manufacturer model = Manufacturer
fields = ( fields = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags',
'actions',
) )

View File

@ -24,11 +24,14 @@ class RackRoleTable(BaseTable):
name = tables.Column(linkify=True) name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks') rack_count = tables.Column(verbose_name='Racks')
color = ColorColumn() color = ColorColumn()
tags = TagColumn(
url_name='dcim:rackrole_list'
)
actions = ButtonsColumn(RackRole) actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackRole model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')

View File

@ -29,11 +29,14 @@ class RegionTable(BaseTable):
url_params={'region_id': 'pk'}, url_params={'region_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
tags = TagColumn(
url_name='dcim:region_list'
)
actions = ButtonsColumn(Region) actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Region model = Region
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
tags = TagColumn(
url_name='dcim:sitegroup_list'
)
actions = ButtonsColumn(SiteGroup) actions = ButtonsColumn(SiteGroup)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = SiteGroup model = SiteGroup
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -114,6 +120,9 @@ class LocationTable(BaseTable):
url_params={'location_id': 'pk'}, url_params={'location_id': 'pk'},
verbose_name='Devices' verbose_name='Devices'
) )
tags = TagColumn(
url_name='dcim:location_list'
)
actions = ButtonsColumn( actions = ButtonsColumn(
model=Location, model=Location,
prepend_template=LOCATION_ELEVATIONS prepend_template=LOCATION_ELEVATIONS
@ -121,5 +130,7 @@ class LocationTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Location model = Location
fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions') fields = (
'pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

View File

@ -2838,6 +2838,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
@ -2853,9 +2854,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]), Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]), Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
@ -2882,12 +2883,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables # Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self): def test_label(self):
@ -2940,9 +2941,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_tenant(self): def test_tenant(self):
tenant = Tenant.objects.all()[:2] tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]} params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenant[0].slug, tenant[1].slug]} params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_termination_types(self): def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'} params = {'termination_a_type': 'dcim.consoleport'}

View File

@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for region in regions: for region in regions:
region.save() region.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Region X', 'name': 'Region X',
'slug': 'region-x', 'slug': 'region-x',
'parent': regions[2].pk, 'parent': regions[2].pk,
'description': 'A new region', 'description': 'A new region',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for sitegroup in sitegroups: for sitegroup in sitegroups:
sitegroup.save() sitegroup.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Site Group X', 'name': 'Site Group X',
'slug': 'site-group-x', 'slug': 'site-group-x',
'parent': sitegroups[2].pk, 'parent': sitegroups[2].pk,
'description': 'A new site group', 'description': 'A new site group',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for location in locations: for location in locations:
location.save() location.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Location X', 'name': 'Location X',
'slug': 'location-x', 'slug': 'location-x',
'site': site.pk, 'site': site.pk,
'tenant': tenant.pk, 'tenant': tenant.pk,
'description': 'A new location', 'description': 'A new location',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
RackRole(name='Rack Role 3', slug='rack-role-3'), RackRole(name='Rack Role 3', slug='rack-role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Rack Role X', 'name': 'Rack Role X',
'slug': 'rack-role-x', 'slug': 'rack-role-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'description': 'New role', 'description': 'New role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Manufacturer X', 'name': 'Manufacturer X',
'slug': 'manufacturer-x', 'slug': 'manufacturer-x',
'description': 'A new manufacturer', 'description': 'A new manufacturer',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -435,6 +450,116 @@ class DeviceTypeTestCase(
'is_full_depth': False, 'is_full_depth': False,
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_consoleports(self):
devicetype = DeviceType.objects.first()
console_ports = (
ConsolePortTemplate(device_type=devicetype, name='Console Port 1'),
ConsolePortTemplate(device_type=devicetype, name='Console Port 2'),
ConsolePortTemplate(device_type=devicetype, name='Console Port 3'),
)
ConsolePortTemplate.objects.bulk_create(console_ports)
url = reverse('dcim:devicetype_consoleports', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_consoleserverports(self):
devicetype = DeviceType.objects.first()
console_server_ports = (
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 1'),
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 2'),
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 3'),
)
ConsoleServerPortTemplate.objects.bulk_create(console_server_ports)
url = reverse('dcim:devicetype_consoleserverports', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_powerports(self):
devicetype = DeviceType.objects.first()
power_ports = (
PowerPortTemplate(device_type=devicetype, name='Power Port 1'),
PowerPortTemplate(device_type=devicetype, name='Power Port 2'),
PowerPortTemplate(device_type=devicetype, name='Power Port 3'),
)
PowerPortTemplate.objects.bulk_create(power_ports)
url = reverse('dcim:devicetype_powerports', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_poweroutlets(self):
devicetype = DeviceType.objects.first()
power_outlets = (
PowerOutletTemplate(device_type=devicetype, name='Power Outlet 1'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet 2'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet 3'),
)
PowerOutletTemplate.objects.bulk_create(power_outlets)
url = reverse('dcim:devicetype_poweroutlets', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_interfaces(self):
devicetype = DeviceType.objects.first()
interfaces = (
InterfaceTemplate(device_type=devicetype, name='Interface 1'),
InterfaceTemplate(device_type=devicetype, name='Interface 2'),
InterfaceTemplate(device_type=devicetype, name='Interface 3'),
)
InterfaceTemplate.objects.bulk_create(interfaces)
url = reverse('dcim:devicetype_interfaces', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_rearports(self):
devicetype = DeviceType.objects.first()
rear_ports = (
RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
RearPortTemplate(device_type=devicetype, name='Rear Port 2'),
RearPortTemplate(device_type=devicetype, name='Rear Port 3'),
)
RearPortTemplate.objects.bulk_create(rear_ports)
url = reverse('dcim:devicetype_rearports', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_frontports(self):
devicetype = DeviceType.objects.first()
rear_ports = (
RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
RearPortTemplate(device_type=devicetype, name='Rear Port 2'),
RearPortTemplate(device_type=devicetype, name='Rear Port 3'),
)
RearPortTemplate.objects.bulk_create(rear_ports)
front_ports = (
FrontPortTemplate(device_type=devicetype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
FrontPortTemplate(device_type=devicetype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
FrontPortTemplate(device_type=devicetype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
)
FrontPortTemplate.objects.bulk_create(front_ports)
url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_devicetype_devicebays(self):
devicetype = DeviceType.objects.first()
device_bays = (
DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'),
)
DeviceBayTemplate.objects.bulk_create(device_bays)
url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_objects(self): def test_import_objects(self):
""" """
@ -924,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
DeviceRole(name='Device Role 3', slug='device-role-3'), DeviceRole(name='Device Role 3', slug='device-role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Devie Role X', 'name': 'Devie Role X',
'slug': 'device-role-x', 'slug': 'device-role-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'vm_role': False, 'vm_role': False,
'description': 'New device role', 'description': 'New device role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -959,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Platform X', 'name': 'Platform X',
'slug': 'platform-x', 'slug': 'platform-x',
@ -966,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'napalm_driver': 'junos', 'napalm_driver': 'junos',
'napalm_args': None, 'napalm_args': None,
'description': 'A new platform', 'description': 'A new platform',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -109,6 +109,14 @@ urlpatterns = [
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'), path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path('device-types/<int:pk>/console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'),
path('device-types/<int:pk>/console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'),
path('device-types/<int:pk>/power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'),
path('device-types/<int:pk>/power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'),
path('device-types/<int:pk>/interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
path('device-types/<int:pk>/front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),

View File

@ -36,6 +36,37 @@ from .models import (
) )
class DeviceComponentsView(generic.ObjectView):
queryset = Device.objects.all()
model = None
table = None
def get_components(self, request, instance):
return self.model.objects.restrict(request.user, 'view').filter(device=instance)
def get_extra_context(self, request, instance):
components = self.get_components(request, instance)
table = self.table(data=components, user=request.user)
change_perm = f'{self.model._meta.app_label}.change_{self.model._meta.model_name}'
delete_perm = f'{self.model._meta.app_label}.delete_{self.model._meta.model_name}'
if request.user.has_perm(change_perm) or request.user.has_perm(delete_perm):
table.columns.show('pk')
paginate_table(table, request)
return {
'table': table,
'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}",
}
class DeviceTypeComponentsView(DeviceComponentsView):
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
def get_components(self, request, instance):
return self.model.objects.restrict(request.user, 'view').filter(device_type=instance)
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
""" """
An extendable view for disconnection console/power/interface components in bulk. An extendable view for disconnection console/power/interface components in bulk.
@ -759,62 +790,52 @@ class DeviceTypeView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
# Component tables
consoleport_table = tables.ConsolePortTemplateTable(
ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
powerport_table = tables.PowerPortTemplateTable(
PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
poweroutlet_table = tables.PowerOutletTemplateTable(
PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=instance)),
orderable=False
)
front_port_table = tables.FrontPortTemplateTable(
FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
rear_port_table = tables.RearPortTemplateTable(
RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
devicebay_table = tables.DeviceBayTemplateTable(
DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=instance),
orderable=False
)
if request.user.has_perm('dcim.change_devicetype'):
consoleport_table.columns.show('pk')
consoleserverport_table.columns.show('pk')
powerport_table.columns.show('pk')
poweroutlet_table.columns.show('pk')
interface_table.columns.show('pk')
front_port_table.columns.show('pk')
rear_port_table.columns.show('pk')
devicebay_table.columns.show('pk')
return { return {
'instance_count': instance_count, 'instance_count': instance_count,
'consoleport_table': consoleport_table, 'active_tab': 'devicetype',
'consoleserverport_table': consoleserverport_table,
'powerport_table': powerport_table,
'poweroutlet_table': poweroutlet_table,
'interface_table': interface_table,
'front_port_table': front_port_table,
'rear_port_table': rear_port_table,
'devicebay_table': devicebay_table,
} }
class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
model = ConsolePortTemplate
table = tables.ConsolePortTemplateTable
class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
model = ConsoleServerPortTemplate
table = tables.ConsoleServerPortTemplateTable
class DeviceTypePowerPortsView(DeviceTypeComponentsView):
model = PowerPortTemplate
table = tables.PowerPortTemplateTable
class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
model = PowerOutletTemplate
table = tables.PowerOutletTemplateTable
class DeviceTypeInterfacesView(DeviceTypeComponentsView):
model = InterfaceTemplate
table = tables.InterfaceTemplateTable
class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
model = FrontPortTemplate
table = tables.FrontPortTemplateTable
class DeviceTypeRearPortsView(DeviceTypeComponentsView):
model = RearPortTemplate
table = tables.RearPortTemplateTable
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
model = DeviceBayTemplate
table = tables.DeviceBayTemplateTable
class DeviceTypeEditView(generic.ObjectEditView): class DeviceTypeEditView(generic.ObjectEditView):
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeForm model_form = forms.DeviceTypeForm
@ -1306,206 +1327,65 @@ class DeviceView(generic.ObjectView):
} }
class DeviceConsolePortsView(generic.ObjectView): class DeviceConsolePortsView(DeviceComponentsView):
queryset = Device.objects.all() model = ConsolePort
table = tables.DeviceConsolePortTable
template_name = 'dcim/device/consoleports.html' template_name = 'dcim/device/consoleports.html'
def get_extra_context(self, request, instance):
consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
'cable', '_path__destination',
)
consoleport_table = tables.DeviceConsolePortTable(
data=consoleports,
user=request.user
)
if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'):
consoleport_table.columns.show('pk')
paginate_table(consoleport_table, request)
return { class DeviceConsoleServerPortsView(DeviceComponentsView):
'consoleport_table': consoleport_table, model = ConsoleServerPort
'active_tab': 'console-ports', table = tables.DeviceConsoleServerPortTable
}
class DeviceConsoleServerPortsView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/consoleserverports.html' template_name = 'dcim/device/consoleserverports.html'
def get_extra_context(self, request, instance):
consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter(
device=instance
).prefetch_related(
'cable', '_path__destination',
)
consoleserverport_table = tables.DeviceConsoleServerPortTable(
data=consoleserverports,
user=request.user
)
if request.user.has_perm('dcim.change_consoleserverport') or \
request.user.has_perm('dcim.delete_consoleserverport'):
consoleserverport_table.columns.show('pk')
paginate_table(consoleserverport_table, request)
return { class DevicePowerPortsView(DeviceComponentsView):
'consoleserverport_table': consoleserverport_table, model = PowerPort
'active_tab': 'console-server-ports', table = tables.DevicePowerPortTable
}
class DevicePowerPortsView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/powerports.html' template_name = 'dcim/device/powerports.html'
def get_extra_context(self, request, instance):
powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
'cable', '_path__destination',
)
powerport_table = tables.DevicePowerPortTable(
data=powerports,
user=request.user
)
if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'):
powerport_table.columns.show('pk')
paginate_table(powerport_table, request)
return { class DevicePowerOutletsView(DeviceComponentsView):
'powerport_table': powerport_table, model = PowerOutlet
'active_tab': 'power-ports', table = tables.DevicePowerOutletTable
}
class DevicePowerOutletsView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/poweroutlets.html' template_name = 'dcim/device/poweroutlets.html'
def get_extra_context(self, request, instance):
poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
'cable', 'power_port', '_path__destination',
)
poweroutlet_table = tables.DevicePowerOutletTable(
data=poweroutlets,
user=request.user
)
if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'):
poweroutlet_table.columns.show('pk')
paginate_table(poweroutlet_table, request)
return { class DeviceInterfacesView(DeviceComponentsView):
'poweroutlet_table': poweroutlet_table, model = Interface
'active_tab': 'power-outlets', table = tables.DeviceInterfaceTable
}
class DeviceInterfacesView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/interfaces.html' template_name = 'dcim/device/interfaces.html'
def get_extra_context(self, request, instance): def get_components(self, request, instance):
interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user))
'lag', 'cable', '_path__destination', 'tags',
) )
interface_table = tables.DeviceInterfaceTable(
data=interfaces,
user=request.user
)
if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'):
interface_table.columns.show('pk')
paginate_table(interface_table, request)
return {
'interface_table': interface_table,
'active_tab': 'interfaces',
}
class DeviceFrontPortsView(generic.ObjectView): class DeviceFrontPortsView(DeviceComponentsView):
queryset = Device.objects.all() model = FrontPort
table = tables.DeviceFrontPortTable
template_name = 'dcim/device/frontports.html' template_name = 'dcim/device/frontports.html'
def get_extra_context(self, request, instance):
frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
'rear_port', 'cable',
)
frontport_table = tables.DeviceFrontPortTable(
data=frontports,
user=request.user
)
if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'):
frontport_table.columns.show('pk')
paginate_table(frontport_table, request)
return { class DeviceRearPortsView(DeviceComponentsView):
'frontport_table': frontport_table, model = RearPort
'active_tab': 'front-ports', table = tables.DeviceRearPortTable
}
class DeviceRearPortsView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/rearports.html' template_name = 'dcim/device/rearports.html'
def get_extra_context(self, request, instance):
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable')
rearport_table = tables.DeviceRearPortTable(
data=rearports,
user=request.user
)
if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'):
rearport_table.columns.show('pk')
paginate_table(rearport_table, request)
return { class DeviceDeviceBaysView(DeviceComponentsView):
'rearport_table': rearport_table, model = DeviceBay
'active_tab': 'rear-ports', table = tables.DeviceDeviceBayTable
}
class DeviceDeviceBaysView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
def get_extra_context(self, request, instance):
devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related(
'installed_device__device_type__manufacturer',
)
devicebay_table = tables.DeviceDeviceBayTable(
data=devicebays,
user=request.user
)
if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'):
devicebay_table.columns.show('pk')
paginate_table(devicebay_table, request)
return { class DeviceInventoryView(DeviceComponentsView):
'devicebay_table': devicebay_table, model = InventoryItem
'active_tab': 'device-bays', table = tables.DeviceInventoryItemTable
}
class DeviceInventoryView(generic.ObjectView):
queryset = Device.objects.all()
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
def get_extra_context(self, request, instance):
inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter(
device=instance
).prefetch_related('manufacturer')
inventoryitem_table = tables.DeviceInventoryItemTable(
data=inventoryitems,
user=request.user
)
if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'):
inventoryitem_table.columns.show('pk')
paginate_table(inventoryitem_table, request)
return {
'inventoryitem_table': inventoryitem_table,
'active_tab': 'inventory',
}
class DeviceStatusView(generic.ObjectView): class DeviceStatusView(generic.ObjectView):
additional_permissions = ['dcim.napalm_read_device'] additional_permissions = ['dcim.napalm_read_device']

View File

@ -15,6 +15,7 @@ from .models import *
__all__ = ( __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet):
] ]
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(BaseFilterSet):
content_types = ContentTypeFilter() content_types = ContentTypeFilter()
class Meta: class Meta:

View File

@ -3,14 +3,12 @@ from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from ipam.choices import * from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import * from ipam.models import *
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer
from netbox.api.serializers import PrimaryModelSerializer from netbox.api.serializers import PrimaryModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -67,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer):
# RIRs/aggregates # RIRs/aggregates
# #
class RIRSerializer(OrganizationalModelSerializer): class RIRSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True) aggregate_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RIR model = RIR
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'aggregate_count', 'last_updated', 'aggregate_count',
] ]
@ -98,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer):
# VLANs # VLANs
# #
class RoleSerializer(OrganizationalModelSerializer): class RoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
@ -106,43 +104,32 @@ class RoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = Role model = Role
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
'prefix_count', 'vlan_count', 'last_updated', 'prefix_count', 'vlan_count',
] ]
class VLANGroupSerializer(OrganizationalModelSerializer): class VLANGroupSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
scope_type = ContentTypeField( scope_type = ContentTypeField(
queryset=ContentType.objects.filter( queryset=ContentType.objects.filter(
model__in=VLANGROUP_SCOPE_TYPES model__in=VLANGROUP_SCOPE_TYPES
), ),
required=False required=False,
default=None
) )
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True) scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags',
'created', 'last_updated', 'vlan_count', 'custom_fields', 'created', 'last_updated', 'vlan_count',
] ]
validators = [] validators = []
def validate(self, data):
# Validate uniqueness of name and slug if a site has been assigned.
if data.get('site', None):
for field in ['name', 'slug']:
validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field))
validator(data, self)
# Enforce model validation
super().validate(data)
return data
def get_scope(self, obj): def get_scope(self, obj):
if obj.scope_id is None: if obj.scope_id is None:
return None return None
@ -155,7 +142,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
class VLANSerializer(PrimaryModelSerializer): class VLANSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
@ -167,20 +154,6 @@ class VLANSerializer(PrimaryModelSerializer):
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'prefix_count', 'custom_fields', 'created', 'last_updated', 'prefix_count',
] ]
validators = []
def validate(self, data):
# Validate uniqueness of vid and name if a group has been assigned.
if data.get('group', None):
for field in ['vid', 'name']:
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field))
validator(data, self)
# Enforce model validation
super().validate(data)
return data
# #

View File

@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
class RIRViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
) ).prefetch_related('tags')
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
filterset_class = filtersets.RIRFilterSet filterset_class = filtersets.RIRFilterSet
@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role') vlan_count=count_related(VLAN, 'role')
) ).prefetch_related('tags')
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
filterset_class = filtersets.RoleFilterSet filterset_class = filtersets.RoleFilterSet
@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
) ).prefetch_related('tags')
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet

View File

@ -71,7 +71,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
] ]
class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
} }
class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
] ]
class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput

View File

@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RIRForm(BootstrapMixin, CustomFieldModelForm): class RIRForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = RIR model = RIR
fields = [ fields = [
'name', 'slug', 'is_private', 'description', 'name', 'slug', 'is_private', 'description', 'tags',
] ]
@ -120,11 +124,15 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RoleForm(BootstrapMixin, CustomFieldModelForm): class RoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Role model = Role
fields = [ fields = [
'name', 'slug', 'weight', 'description', 'name', 'slug', 'weight', 'description', 'tags',
] ]
@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
} }
) )
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'clustergroup', 'cluster', 'tags',
] ]
fieldsets = ( fieldsets = (
('VLAN Group', ('name', 'slug', 'description')), ('VLAN Group', ('name', 'slug', 'description', 'tags')),
('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
) )
widgets = { widgets = {

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.8 on 2021-10-21 14:50
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0062_clear_secrets_changelog'),
('ipam', '0050_iprange'),
]
operations = [
migrations.AddField(
model_name='rir',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='role',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='vlangroup',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@ -31,7 +31,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RIR(OrganizationalModel): class RIR(OrganizationalModel):
""" """
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@ -168,7 +168,7 @@ class Aggregate(PrimaryModel):
return min(utilization, 100) return min(utilization, 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Role(OrganizationalModel): class Role(OrganizationalModel):
""" """
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or

View File

@ -21,7 +21,7 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VLANGroup(OrganizationalModel): class VLANGroup(OrganizationalModel):
""" """
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.

View File

@ -85,11 +85,14 @@ class RIRTable(BaseTable):
url_params={'rir_id': 'pk'}, url_params={'rir_id': 'pk'},
verbose_name='Aggregates' verbose_name='Aggregates'
) )
tags = TagColumn(
url_name='ipam:rir_list'
)
actions = ButtonsColumn(RIR) actions = ButtonsColumn(RIR)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RIR model = RIR
fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions') fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
@ -144,11 +147,14 @@ class RoleTable(BaseTable):
url_params={'role_id': 'pk'}, url_params={'role_id': 'pk'},
verbose_name='VLANs' verbose_name='VLANs'
) )
tags = TagColumn(
url_name='ipam:role_list'
)
actions = ButtonsColumn(Role) actions = ButtonsColumn(Role)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Role model = Role
fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions') fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
@ -260,11 +266,16 @@ class IPRangeTable(BaseTable):
linkify=True linkify=True
) )
tenant = TenantColumn() tenant = TenantColumn()
utilization = UtilizationColumn(
accessor='utilization',
orderable=False
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPRange model = IPRange
fields = ( fields = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization',
) )
default_columns = ( default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',

View File

@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='VLANs' verbose_name='VLANs'
) )
tags = TagColumn(
url_name='ipam:vlangroup_list'
)
actions = ButtonsColumn( actions = ButtonsColumn(
model=VLANGroup, model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN prepend_template=VLANGROUP_ADD_VLAN
@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLANGroup model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')

View File

@ -104,11 +104,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
RIR(name='RIR 3', slug='rir-3'), RIR(name='RIR 3', slug='rir-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'RIR X', 'name': 'RIR X',
'slug': 'rir-x', 'slug': 'rir-x',
'is_private': True, 'is_private': True,
'description': 'A new RIR', 'description': 'A new RIR',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -177,11 +180,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
Role(name='Role 3', slug='role-3'), Role(name='Role 3', slug='role-3'),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Role X', 'name': 'Role X',
'slug': 'role-x', 'slug': 'role-x',
'weight': 200, 'weight': 200,
'description': 'A new role', 'description': 'A new role',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -384,10 +390,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
]) ])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'VLAN Group X', 'name': 'VLAN Group X',
'slug': 'vlan-group-x', 'slug': 'vlan-group-x',
'description': 'A new VLAN group', 'description': 'A new VLAN group',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer):
# Base model serializers # Base model serializers
# #
class OrganizationalModelSerializer(CustomFieldModelSerializer):
"""
Adds support for custom fields.
"""
pass
class PrimaryModelSerializer(CustomFieldModelSerializer): class PrimaryModelSerializer(CustomFieldModelSerializer):
""" """
Adds support for custom fields and tags. Adds support for custom fields and tags.
@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer):
return instance return instance
class NestedGroupModelSerializer(CustomFieldModelSerializer): class NestedGroupModelSerializer(PrimaryModelSerializer):
""" """
Extends OrganizationalModelSerializer to include MPTT support. Extends PrimaryModelSerializer to include MPTT support.
""" """
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)

View File

@ -41,6 +41,7 @@ class ObjectType(
class OrganizationalObjectType( class OrganizationalObjectType(
ChangelogMixin, ChangelogMixin,
CustomFieldsMixin, CustomFieldsMixin,
TagsMixin,
BaseObjectType BaseObjectType
): ):
""" """

View File

@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model):
post_clean.send(sender=self.__class__, instance=self) post_clean.send(sender=self.__class__, instance=self)
class TagsMixin(models.Model):
"""
Enable the assignment of Tags.
"""
tags = TaggableManager(
through='extras.TaggedItem'
)
class Meta:
abstract = True
# #
# Base model classes # Base model classes
@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
abstract = True abstract = True
class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
""" """
Primary models represent real objects within the infrastructure being modeled. Primary models represent real objects within the infrastructure being modeled.
""" """
@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
object_id_field='assigned_object_id', object_id_field='assigned_object_id',
content_type_field='assigned_object_type' content_type_field='assigned_object_type'
) )
tags = TaggableManager(
through='extras.TaggedItem'
)
class Meta: class Meta:
abstract = True abstract = True
class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel, MPTTModel): class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel):
""" """
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using MPTT. Within each parent, each child instance must have a unique name. recursively using MPTT. Within each parent, each child instance must have a unique name.
@ -225,7 +234,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi
}) })
class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
""" """
Organizational models are those which are used solely to categorize and qualify other objects, and do not convey Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
any real information about the infrastructure being modeled (for example, functional device roles). Organizational any real information about the infrastructure being modeled (for example, functional device roles). Organizational

View File

@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'), get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'),
), ),
), ),
MenuGroup(
label='Contacts',
items=(
get_model_item('tenancy', 'contact', 'Contacts'),
get_model_item('tenancy', 'contactgroup', 'Contact Groups'),
get_model_item('tenancy', 'contactrole', 'Contact Roles'),
),
),
), ),
) )

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '3.0.8-dev' VERSION = '3.0.9-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -137,7 +137,7 @@ class HomeView(View):
release_version, release_url = latest_release release_version, release_url = latest_release
if release_version > version.parse(settings.VERSION): if release_version > version.parse(settings.VERSION):
new_release = { new_release = {
'version': str(latest_release), 'version': str(release_version),
'url': release_url, 'url': release_url,
} }

View File

@ -282,11 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
if '_addanother' in request.POST: if '_addanother' in request.POST:
redirect_url = request.get_full_path() redirect_url = request.path
# If the object has clone_fields, pre-populate a new instance of the form # If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'): if hasattr(obj, 'clone_fields'):
redirect_url += f"{'&' if '?' in redirect_url else '?'}{prepare_cloned_fields(obj)}" redirect_url += f"?{prepare_cloned_fields(obj)}"
return redirect(redirect_url) return redirect(redirect_url)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,17 @@
import { createToast } from '../bs'; import { createToast } from '../bs';
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util'; import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
// Match an interface name that begins with a capital letter and is followed by at least one other
// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2.
const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/);
// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use
// the first two characters).
const CISCO_IOS_OVERRIDES = new Map<string, string>([
// Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
['TwentyFiveGigE', 'Twe'],
]);
/** /**
* Get an attribute from a row's cell. * Get an attribute from a row's cell.
* *
@ -12,6 +23,40 @@ function getData(row: HTMLTableRowElement, query: string, attr: string): string
return row.querySelector(query)?.getAttribute(attr) ?? null; return row.querySelector(query)?.getAttribute(attr) ?? null;
} }
/**
* Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS
* interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2`
* would become `Gi0/1/2`.
*
* This should probably be replaced with something in the primary application (Django), such as
* a database field attached to given interface types. However, this is a temporary measure to
* replace the functionality of this one-liner:
*
* @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69
*
* @param name Long-form/original interface name.
*/
function getInterfaceAlias(name: string | null): string | null {
if (name === null) {
return name;
}
if (name.match(CISCO_IOS_PATTERN)) {
// Extract the base name and numeric portions of the interface. For example, an input interface
// of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`.
const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3);
if (isTruthy(base) && isTruthy(numeric)) {
// Check the override map and use its value if the base name is present in the map.
// Otherwise, use the first two characters of the base name. For example,
// `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become
// `Twe0/0/1`.
const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2);
return `${aliasBase}${numeric}`;
}
}
return name;
}
/** /**
* Update row styles based on LLDP neighbor data. * Update row styles based on LLDP neighbor data.
*/ */
@ -23,38 +68,41 @@ function updateRowStyle(data: LLDPNeighborDetail) {
if (row !== null) { if (row !== null) {
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
const cellDevice = row.querySelector<HTMLTableCellElement>('td.device'); const deviceCell = row.querySelector<HTMLTableCellElement>('td.device');
const cellInterface = row.querySelector<HTMLTableCellElement>('td.interface'); const interfaceCell = row.querySelector<HTMLTableCellElement>('td.interface');
const cDevice = getData(row, 'td.configured_device', 'data'); const configuredDevice = getData(row, 'td.configured_device', 'data');
const cChassis = getData(row, 'td.configured_chassis', 'data-chassis'); const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
const cInterface = getData(row, 'td.configured_interface', 'data'); const configuredIface = getData(row, 'td.configured_interface', 'data');
let cInterfaceShort = null; const interfaceAlias = getInterfaceAlias(configuredIface);
if (isTruthy(cInterface)) {
cInterfaceShort = cInterface.replace(/^([A-Z][a-z])[^0-9]*([0-9/]+)$/, '$1$2'); const remoteName = neighbor.remote_system_name ?? '';
const remotePort = neighbor.remote_port ?? '';
const [neighborDevice] = remoteName.split('.');
const [neighborIface] = remotePort.split('.');
if (deviceCell !== null) {
deviceCell.innerText = neighborDevice;
} }
const nHost = neighbor.remote_system_name ?? ''; if (interfaceCell !== null) {
const nPort = neighbor.remote_port ?? ''; interfaceCell.innerText = neighborIface;
const [nDevice] = nHost.split('.');
const [nInterface] = nPort.split('.');
if (cellDevice !== null) {
cellDevice.innerText = nDevice;
} }
if (cellInterface !== null) { // Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
cellInterface.innerText = nInterface; const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice);
}
if (!isTruthy(cDevice) && isTruthy(nDevice)) { // NetBox device or chassis matches LLDP neighbor.
const validNode =
configuredDevice === neighborDevice || configuredChassis === neighborDevice;
// NetBox configured interface matches LLDP neighbor interface.
const validInterface =
configuredIface === neighborIface || interfaceAlias === neighborIface;
if (nonConfiguredDevice) {
row.classList.add('info'); row.classList.add('info');
} else if ( } else if (validNode && validInterface) {
(cDevice === nDevice || cChassis === nDevice) &&
cInterfaceShort === nInterface
) {
row.classList.add('success');
} else if (cDevice === nDevice || cChassis === nDevice) {
row.classList.add('success'); row.classList.add('success');
} else { } else {
row.classList.add('danger'); row.classList.add('danger');

View File

@ -266,10 +266,8 @@ class SideNav {
for (const link of this.getActiveLinks()) { for (const link of this.getActiveLinks()) {
this.activateLink(link, 'collapse'); this.activateLink(link, 'collapse');
} }
setTimeout(() => {
this.bodyRemove('hide'); this.bodyRemove('hide');
this.bodyAdd('hidden'); this.bodyAdd('hidden');
}, 300);
} }
} }

View File

@ -197,9 +197,15 @@ table {
text-decoration: underline; text-decoration: underline;
} }
} }
.dropdown {
// Presence of 'overflow: scroll' on a table causes dropdowns to be improperly hidden when
// opened. See: https://github.com/twbs/bootstrap/issues/24251
position: static;
}
} }
th { th {
a, a:hover { a,
a:hover {
color: $body-color; color: $body-color;
text-decoration: none; text-decoration: none;
} }

View File

@ -105,6 +105,11 @@
// Navbar brand // Navbar brand
.sidenav-brand { .sidenav-brand {
margin-right: 0; margin-right: 0;
transition: opacity 0.1s ease-in-out;
}
.sidenav-brand-icon {
transition: opacity 0.1s ease-in-out;
} }
.sidenav-inner { .sidenav-inner {
@ -141,7 +146,17 @@
} }
.sidenav-toggle { .sidenav-toggle {
display: none; // The sidenav toggle's default state is "hidden". Because modifying the `display` property
// isn't ideal for smooth transitions, combine opacity 0 (transparent) and position absolute
// to yield a similar result.
position: absolute;
display: inline-block;
opacity: 0;
// The transition itself is largely irrelevant, but CSS needs *something* to transition in
// order to apply a delay.
transition: opacity 10ms ease-in-out;
// Offset the transition delay so the icon isn't visible during the logo transition.
transition-delay: 0.1s;
} }
.sidenav-collapse { .sidenav-collapse {
@ -350,13 +365,21 @@
.sidenav-brand { .sidenav-brand {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
transform: translateX(-150%);
} }
.sidenav-brand-icon { .sidenav-brand-icon {
opacity: 1; opacity: 1;
} }
.sidenav-toggle {
// Immediately hide the toggle when the sidenav is closed, so it doesn't linger and overlap
// with the logo elements.
opacity: 0;
position: absolute;
transition: unset;
transition-delay: 0ms;
}
.navbar-nav > .nav-item { .navbar-nav > .nav-item {
> .nav-link { > .nav-link {
&:after { &:after {
@ -402,7 +425,8 @@
@include media-breakpoint-up(lg) { @include media-breakpoint-up(lg) {
.sidenav-toggle { .sidenav-toggle {
display: inline-block; position: relative;
opacity: 1;
} }
} }
} }

View File

@ -74,6 +74,7 @@ $btn-link-disabled-color: $gray-300;
// Forms // Forms
$component-active-bg: $primary; $component-active-bg: $primary;
$component-active-color: $black;
$form-text-color: $text-muted; $form-text-color: $text-muted;
$input-bg: $gray-900; $input-bg: $gray-900;
$input-disabled-bg: $gray-700; $input-disabled-bg: $gray-700;

View File

@ -64,15 +64,16 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:circuit_list' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/comments_panel.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/image_attachments_panel.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -28,10 +28,11 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -47,12 +47,13 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/comments_panel.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
<div class="col col-md-12"> <div class="col col-md-12">

View File

@ -37,9 +37,9 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:providernetwork_list' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/comments_panel.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -23,6 +23,19 @@
<span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span> <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Tenant</th>
<td>
{% if object.tenant %}
{% if object.tenant.group %}
<a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
{% endif %}
<a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Label</th> <th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td> <td>{{ object.label|placeholder }}</td>
@ -50,8 +63,8 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:cable_list' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -40,8 +40,8 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -40,8 +40,8 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -220,9 +220,9 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/comments_panel.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
@ -296,7 +296,8 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include 'inc/image_attachments_panel.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
<div class="card noprint"> <div class="card noprint">
<h5 class="card-header"> <h5 class="card-header">
Related Devices Related Devices

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %} {% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %}
{% render_table consoleport_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_consoleport %} {% if perms.dcim.change_consoleport %}
@ -36,6 +36,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=consoleport_table.paginator page=consoleport_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form consoleport_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %} {% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %}
{% render_table consoleserverport_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_consoleserverport %} {% if perms.dcim.change_consoleserverport %}
@ -36,6 +36,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=consoleserverport_table.paginator page=consoleserverport_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form consoleserverport_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %} {% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %}
{% render_table devicebay_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_devicebay %} {% if perms.dcim.change_devicebay %}
@ -33,6 +33,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=devicebay_table.paginator page=devicebay_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form devicebay_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %} {% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %}
{% render_table frontport_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_frontport %} {% if perms.dcim.change_frontport %}
@ -36,6 +36,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=frontport_table.paginator page=frontport_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form frontport_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -34,7 +34,7 @@
</div> </div>
</div> </div>
</div> </div>
{% render_table interface_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}
@ -63,6 +63,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=interface_table.paginator page=interface_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form interface_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %} {% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %}
{% render_table inventoryitem_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_inventoryitem %} {% if perms.dcim.change_inventoryitem %}
@ -33,6 +33,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=inventoryitem_table.paginator page=inventoryitem_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form inventoryitem_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %} {% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %}
{% render_table poweroutlet_table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}
@ -36,6 +36,6 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% include 'inc/paginator.html' with paginator=poweroutlet_table.paginator page=poweroutlet_table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form poweroutlet_table %} {% table_config_form table %}
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More