diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 2a1ecd5d0..318a9b7ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: 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/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.7 + placeholder: v3.0.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 6a3f81e1e..be89acfad 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.7 + placeholder: v3.0.8 validations: required: true - type: dropdown diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md new file mode 100644 index 000000000..76a005fc0 --- /dev/null +++ b/docs/core-functionality/contacts.md @@ -0,0 +1,5 @@ +# Contacts + +{!models/tenancy/contact.md!} +{!models/tenancy/contactgroup.md!} +{!models/tenancy/contactrole.md!} diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md deleted file mode 100644 index 1ee366cfd..000000000 --- a/docs/customization/custom-links.md +++ /dev/null @@ -1 +0,0 @@ -{!models/extras/customlink.md!} diff --git a/docs/development/models.md b/docs/development/models.md index 93a10fff6..59e795cf7 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -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 | | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | -| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | -| Nested Group | :material-check: | :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: | | :material-check: | | Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Component Template | :material-check: | :material-check: | :material-check: | | | | | diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 16df208ac..901a68acf 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -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. -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. + diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 90c9cfe6e..9465a828c 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,6 +1,6 @@ # 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. diff --git a/docs/models/dcim/region.md b/docs/models/dcim/region.md index 734467500..bac186264 100644 --- a/docs/models/dcim/region.md +++ b/docs/models/dcim/region.md @@ -1,3 +1,5 @@ # 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. + +Each region must have a name that is unique within its parent region, if any. diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md index 3c1ed11bd..04ebcc1a5 100644 --- a/docs/models/dcim/sitegroup.md +++ b/docs/models/dcim/sitegroup.md @@ -1,3 +1,5 @@ # 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. + +Each site group must have a name that is unique within its parent group, if any. diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 29cc8b757..fe6a1ef36 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav ```no-highlight 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. diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md new file mode 100644 index 000000000..9d81e2d85 --- /dev/null +++ b/docs/models/tenancy/contact.md @@ -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 diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md new file mode 100644 index 000000000..ea566c58a --- /dev/null +++ b/docs/models/tenancy/contactgroup.md @@ -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. diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md new file mode 100644 index 000000000..23642ca03 --- /dev/null +++ b/docs/models/tenancy/contactrole.md @@ -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. diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 3311ad42d..7fc9bfc06 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # 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. diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 25295b621..69d8b8456 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,27 @@ # 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 --- diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c49552edd..c829ef2b9 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -3,13 +3,65 @@ !!! warning "PostgreSQL 10 Required" 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 * [#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 +* [#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 +* [#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 +* [#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 * [#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 diff --git a/mkdocs.yml b/mkdocs.yml index ac394d704..001808f0d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,10 +63,11 @@ nav: - Wireless: 'core-functionality/wireless.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' + - Contacts: 'core-functionality/contacts.md' - Customization: - Custom Fields: 'customization/custom-fields.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' - Custom Scripts: 'customization/custom-scripts.md' - Reports: 'customization/reports.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index e00b3dfc8..470a0b030 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -5,9 +5,7 @@ from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import LinkTerminationSerializer from netbox.api import ChoiceField -from netbox.api.serializers import ( - OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer -) +from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): # Circuits # -class CircuitTypeSerializer(OrganizationalModelSerializer): +class CircuitTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType 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', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3bceb2de0..2b3e3b122 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(CustomFieldModelViewSet): - queryset = CircuitType.objects.annotate( + queryset = CircuitType.objects.prefetch_related('tags').annotate( circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 638426a5e..7bf5644b9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField ] -class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 659939293..5679dbc94 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = CircuitType fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py new file mode 100644 index 000000000..e5e6ee262 --- /dev/null +++ b/netbox/circuits/migrations/0003_extend_tag_support.py @@ -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'), + ), + ] diff --git a/netbox/circuits/migrations/0003_rename_cable_peer.py b/netbox/circuits/migrations/0004_rename_cable_peer.py similarity index 90% rename from netbox/circuits/migrations/0003_rename_cable_peer.py rename to netbox/circuits/migrations/0004_rename_cable_peer.py index 63dc1006e..81d507eb4 100644 --- a/netbox/circuits/migrations/0003_rename_cable_peer.py +++ b/netbox/circuits/migrations/0004_rename_cable_peer.py @@ -4,7 +4,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('circuits', '0002_squashed_0029'), + ('circuits', '0003_extend_tag_support'), ] operations = [ diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 8420db563..089d6cb2d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -62,6 +62,11 @@ class Provider(PrimaryModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() clone_fields = [ @@ -123,7 +128,7 @@ class ProviderNetwork(PrimaryModel): 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): """ 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( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 2e31237b6..d0b0797e2 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable): name = tables.Column( linkify=True ) + tags = TagColumn( + url_name='circuits:circuittype_list' + ) circuit_count = tables.Column( verbose_name='Circuits' ) @@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): 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') diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index ccb4a869a..851d52ae8 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Circuit Type X', 'slug': 'circuit-type-x', 'description': 'A new circuit type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4eeb717d7..bc5e9b54e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,7 +2,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * @@ -12,8 +11,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, - WritableNestedSerializer, + NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer @@ -83,27 +81,27 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer): class RegionSerializer(NestedGroupModelSerializer): 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) class Meta: model = Region fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] class SiteGroupSerializer(NestedGroupModelSerializer): 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) class Meta: model = SiteGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] @@ -146,20 +144,20 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location 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', ] -class RackRoleSerializer(OrganizationalModelSerializer): +class RackRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', - 'rack_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'rack_count', ] @@ -171,6 +169,8 @@ class RackSerializer(PrimaryModelSerializer): status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) 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) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) 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', '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): @@ -271,7 +254,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(OrganizationalModelSerializer): +class ManufacturerSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -280,7 +263,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer): class Meta: model = Manufacturer 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', ] @@ -428,7 +411,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # Devices # -class DeviceRoleSerializer(OrganizationalModelSerializer): +class DeviceRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -436,12 +419,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', - 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(OrganizationalModelSerializer): +class PlatformSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -451,7 +434,7 @@ class PlatformSerializer(OrganizationalModelSerializer): model = Platform fields = [ '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') device_type = NestedDeviceTypeSerializer() 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) site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) - rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) + rack = NestedRackSerializer(required=False, allow_null=True, default=None) + 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) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) @@ -472,7 +456,8 @@ class DeviceSerializer(PrimaryModelSerializer): primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() 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: model = Device @@ -482,19 +467,6 @@ class DeviceSerializer(PrimaryModelSerializer): 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', '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) def get_parent_device(self, obj): @@ -733,7 +705,6 @@ class DeviceBaySerializer(PrimaryModelSerializer): class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() - # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) _depth = serializers.IntegerField(source='level', read_only=True) @@ -761,14 +732,15 @@ class CableSerializer(PrimaryModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=LinkStatusChoices, required=False) + tenant = NestedTenantSerializer(required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable fields = [ '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', - 'custom_fields', + 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', + 'tags', 'custom_fields', ] def _get_termination(self, obj, side): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 43ced046c..9cbdf7d5d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet): 'region', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.RegionSerializer filterset_class = filtersets.RegionFilterSet @@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet): 'group', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.SiteGroupSerializer filterset_class = filtersets.SiteGroupFilterSet @@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet): 'location', 'rack_count', cumulative=True - ).prefetch_related('site') + ).prefetch_related('site', 'tags') serializer_class = serializers.LocationSerializer filterset_class = filtersets.LocationFilterSet @@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet): # class RackRoleViewSet(CustomFieldModelViewSet): - queryset = RackRole.objects.annotate( + queryset = RackRole.objects.prefetch_related('tags').annotate( rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer @@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet): # class ManufacturerViewSet(CustomFieldModelViewSet): - queryset = Manufacturer.objects.annotate( + queryset = Manufacturer.objects.prefetch_related('tags').annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') @@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(CustomFieldModelViewSet): - queryset = DeviceRole.objects.annotate( + queryset = DeviceRole.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') ) @@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet): # class PlatformViewSet(CustomFieldModelViewSet): - queryset = Platform.objects.annotate( + queryset = Platform.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 9f87dded0..9b5363d4c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -704,6 +704,18 @@ class PowerOutletFeedLegChoices(ChoiceSet): # 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): # Virtual diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f6d6ed8dc..f6d8abb0a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1199,7 +1199,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(PrimaryModelFilterSet): +class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1240,14 +1240,6 @@ class CableFilterSet(PrimaryModelFilterSet): method='filter_device', 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() class Meta: diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e8e60a4f9..9abdcb8ff 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -51,7 +51,7 @@ __all__ = ( ) -class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Region.objects.all(), widget=forms.MultipleHiddenInput @@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SiteGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd ] -class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Location.objects.all(), widget=forms.MultipleHiddenInput @@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'tenant', 'description'] -class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackRole.objects.all(), widget=forms.MultipleHiddenInput @@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput @@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel nullable_fields = ['airflow'] -class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput @@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput @@ -468,6 +468,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE widget=StaticSelect(), initial='' ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) label = forms.CharField( max_length=100, required=False @@ -488,7 +492,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE class Meta: nullable_fields = [ - 'type', 'status', 'label', 'color', 'length', + 'type', 'status', 'tenant', 'label', 'color', 'length', ] def clean(self): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4eb860836..f39e3cd7f 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -828,6 +828,12 @@ class CableCSVForm(CustomFieldModelCSVForm): required=False, help_text='Physical medium classification' ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) length_unit = CSVChoiceField( choices=CableLengthUnitChoices, required=False, @@ -838,7 +844,7 @@ class CableCSVForm(CustomFieldModelCSVForm): model = Cable fields = [ '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 = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index a2ceea6cf..770dc211b 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -2,6 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag +from tenancy.forms import TenancyForm from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect __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 """ @@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): model = Cable fields = [ '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 = { 'status': StaticSelect, @@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): +class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -219,7 +221,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) model = Cable fields = [ '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): @@ -227,7 +230,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): +class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): termination_b_region = DynamicModelChoiceField( queryset=Region.objects.all(), label='Region', @@ -280,8 +283,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', - 'color', 'length', 'length_unit', 'tags', + 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', + 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e28714914..5c776386a 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -7,7 +7,6 @@ from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from tenancy.forms import TenancyFilterForm -from tenancy.models import Tenant from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -692,13 +691,13 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod tag = TagFilterField(model) -class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): +class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Cable field_groups = [ ['q', 'tag'], ['site_id', 'rack_id', 'device_id'], ['type', 'status', 'color'], - ['tenant_id'], + ['tenant_group_id', 'tenant_id'], ] q = forms.CharField( required=False, @@ -720,12 +719,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Site'), fetch_trigger='open' ) - tenant_id = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False, - label=_('Tenant'), - fetch_trigger='open' - ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, @@ -973,10 +966,15 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['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'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] + kind = forms.MultipleChoiceField( + choices=InterfaceKindChoices, + required=False, + widget=StaticSelectMultiple() + ) type = forms.MultipleChoiceField( choices=InterfaceTypeChoices, required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 9ce0b54aa..e395c67d2 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -71,11 +71,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -85,11 +89,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -188,15 +196,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Location 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 = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -204,11 +216,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RackRole fields = [ - 'name', 'slug', 'color', 'description', + 'name', 'slug', 'color', 'description', 'tags', ] @@ -344,11 +360,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Manufacturer fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] @@ -393,11 +413,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'description', + 'name', 'slug', 'color', 'vm_role', 'description', 'tags', ] @@ -409,11 +433,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField( max_length=64 ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', ] widgets = { 'napalm_args': SmallTextarea(), @@ -602,7 +630,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] -class CableForm(BootstrapMixin, CustomFieldModelForm): +class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -611,7 +639,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/dcim/migrations/0135_location_tenant.py b/netbox/dcim/migrations/0135_tenancy_extensions.py similarity index 67% rename from netbox/dcim/migrations/0135_location_tenant.py rename to netbox/dcim/migrations/0135_tenancy_extensions.py index 0b1f429f9..673b5027f 100644 --- a/netbox/dcim/migrations/0135_location_tenant.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -15,4 +15,9 @@ class Migration(migrations.Migration): name='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'), + ), ] diff --git a/netbox/dcim/migrations/0136_device_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py index a0887a0b4..94cc89f3f 100644 --- a/netbox/dcim/migrations/0136_device_airflow.py +++ b/netbox/dcim/migrations/0136_device_airflow.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0135_location_tenant'), + ('dcim', '0135_tenancy_extensions'), ] operations = [ diff --git a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py new file mode 100644 index 000000000..8f7d40026 --- /dev/null +++ b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py @@ -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')}, + ), + ] diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py new file mode 100644 index 000000000..763b53c50 --- /dev/null +++ b/netbox/dcim/migrations/0138_extend_tag_support.py @@ -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'), + ), + ] diff --git a/netbox/dcim/migrations/0137_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py similarity index 98% rename from netbox/dcim/migrations/0137_rename_cable_peer.py rename to netbox/dcim/migrations/0139_rename_cable_peer.py index bc13b3e9a..59dc04e2a 100644 --- a/netbox/dcim/migrations/0137_rename_cable_peer.py +++ b/netbox/dcim/migrations/0139_rename_cable_peer.py @@ -4,7 +4,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('dcim', '0136_device_airflow'), + ('dcim', '0138_extend_tag_support'), ] operations = [ diff --git a/netbox/dcim/migrations/0138_wireless.py b/netbox/dcim/migrations/0140_wireless.py similarity index 97% rename from netbox/dcim/migrations/0138_wireless.py rename to netbox/dcim/migrations/0140_wireless.py index bbdb28283..012b78dd4 100644 --- a/netbox/dcim/migrations/0138_wireless.py +++ b/netbox/dcim/migrations/0140_wireless.py @@ -5,7 +5,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('dcim', '0137_rename_cable_peer'), + ('dcim', '0139_rename_cable_peer'), ('wireless', '0001_wireless'), ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 129617746..54012f0e9 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -67,6 +67,13 @@ class Cable(PrimaryModel): choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='cables', + blank=True, + null=True + ) label = models.CharField( max_length=100, blank=True diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 669f5cfbd..2b3b80d24 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -36,7 +36,7 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel): blank=True ) + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + objects = RestrictedQuerySet.as_manager() class Meta: @@ -346,7 +351,7 @@ class DeviceType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ 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]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ 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( blank=True ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 6d6a04cea..30e11b342 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel): name = models.CharField( max_length=100 ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 94e7bf53a..a6d7f33af 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -35,7 +35,7 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -175,12 +175,17 @@ class Rack(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='rack' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index b343f61f2..a978e69e6 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -25,7 +25,7 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ 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 ) name = models.CharField( - max_length=100, - unique=True + max_length=100 ) slug = models.SlugField( - max_length=100, - unique=True + max_length=100 ) description = models.CharField( max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='region' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + class Meta: + unique_together = ( + ('parent', 'name'), + ('parent', 'slug'), + ) def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -73,7 +82,7 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ 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 ) name = models.CharField( - max_length=100, - unique=True + max_length=100 ) slug = models.SlugField( - max_length=100, - unique=True + max_length=100 ) description = models.CharField( max_length=200, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site_group' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + class Meta: + unique_together = ( + ('parent', 'name'), + ('parent', 'slug'), + ) def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -221,12 +239,17 @@ class Site(PrimaryModel): comments = models.TextField( blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='site' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) @@ -255,7 +278,7 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ 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, blank=True ) + + # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', object_id_field='scope_id', related_query_name='location' ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) images = GenericRelation( to='extras.ImageAttachment' ) @@ -305,10 +333,10 @@ class Location(NestedGroupModel): class Meta: ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] + unique_together = ([ + ('site', 'parent', 'name'), + ('site', 'parent', 'slug'), + ]) def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 14cf34505..87913cbfd 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,6 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT @@ -45,6 +46,7 @@ class CableTable(BaseTable): verbose_name='Termination B' ) status = ChoiceFieldColumn() + tenant = TenantColumn() length = TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' @@ -58,7 +60,7 @@ class CableTable(BaseTable): model = Cable fields = ( '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 = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 343667d46..06c594f6b 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -80,11 +80,16 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() + tags = TagColumn( + url_name='dcim:devicerole_list' + ) actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): 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') @@ -107,13 +112,16 @@ class PlatformTable(BaseTable): url_params={'platform_id': 'pk'}, verbose_name='VMs' ) + tags = TagColumn( + url_name='dcim:platform_list' + ) actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform fields = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'actions', + 'description', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index b3310d5d2..9631b5709 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() + tags = TagColumn( + url_name='dcim:manufacturer_list' + ) actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer fields = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags', + 'actions', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index fcc3ed4d2..bdc5ae713 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -24,11 +24,14 @@ class RackRoleTable(BaseTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = ColorColumn() + tags = TagColumn( + url_name='dcim:rackrole_list' + ) actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): 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') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..65419e9c8 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -29,11 +29,14 @@ class RegionTable(BaseTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:region_list' + ) actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): 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') @@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:sitegroup_list' + ) actions = ButtonsColumn(SiteGroup) class Meta(BaseTable.Meta): 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') @@ -114,6 +120,9 @@ class LocationTable(BaseTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) + tags = TagColumn( + url_name='dcim:location_list' + ) actions = ButtonsColumn( model=Location, prepend_template=LOCATION_ELEVATIONS @@ -121,5 +130,7 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): 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') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 62bdaed82..f66ceb855 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2838,6 +2838,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), ) 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') 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 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), - 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 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), + 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 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), @@ -2882,12 +2883,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # 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[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[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[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[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[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[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, 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, 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, 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, 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, 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() def test_label(self): @@ -2940,9 +2941,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_tenant(self): tenant = Tenant.objects.all()[:2] 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]} - 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): params = {'termination_a_type': 'dcim.consoleport'} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c0af2d438..c08eb6e8a 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for region in regions: region.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Region X', 'slug': 'region-x', 'parent': regions[2].pk, 'description': 'A new region', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for sitegroup in sitegroups: sitegroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Site Group X', 'slug': 'site-group-x', 'parent': sitegroups[2].pk, 'description': 'A new site group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for location in locations: location.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, 'tenant': tenant.pk, 'description': 'A new location', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Rack Role X', 'slug': 'rack-role-x', 'color': 'c0c0c0', 'description': 'New role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Manufacturer X', 'slug': 'manufacturer-x', 'description': 'A new manufacturer', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -435,6 +450,116 @@ class DeviceTypeTestCase( '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=['*']) def test_import_objects(self): """ @@ -924,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'device-role-x', 'color': 'c0c0c0', 'vm_role': False, 'description': 'New device role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -959,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Platform X', 'slug': 'platform-x', @@ -966,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'napalm_driver': 'junos', 'napalm_args': None, 'description': 'A new platform', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 01e470e5c..dd81ca2ba 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -109,6 +109,14 @@ urlpatterns = [ path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), + path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), + path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), + path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), + path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), + path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), + path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), + path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 16f88b9c3..5079e01a5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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): """ 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): 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 { 'instance_count': instance_count, - 'consoleport_table': consoleport_table, - '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, + 'active_tab': 'devicetype', } +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): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm @@ -1306,206 +1327,65 @@ class DeviceView(generic.ObjectView): } -class DeviceConsolePortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsolePortsView(DeviceComponentsView): + model = ConsolePort + table = tables.DeviceConsolePortTable 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 { - 'consoleport_table': consoleport_table, - 'active_tab': 'console-ports', - } - - -class DeviceConsoleServerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceConsoleServerPortsView(DeviceComponentsView): + model = ConsoleServerPort + table = tables.DeviceConsoleServerPortTable 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 { - 'consoleserverport_table': consoleserverport_table, - 'active_tab': 'console-server-ports', - } - - -class DevicePowerPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerPortsView(DeviceComponentsView): + model = PowerPort + table = tables.DevicePowerPortTable 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 { - 'powerport_table': powerport_table, - 'active_tab': 'power-ports', - } - - -class DevicePowerOutletsView(generic.ObjectView): - queryset = Device.objects.all() +class DevicePowerOutletsView(DeviceComponentsView): + model = PowerOutlet + table = tables.DevicePowerOutletTable 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 { - 'poweroutlet_table': poweroutlet_table, - 'active_tab': 'power-outlets', - } - - -class DeviceInterfacesView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInterfacesView(DeviceComponentsView): + model = Interface + table = tables.DeviceInterfaceTable template_name = 'dcim/device/interfaces.html' - def get_extra_context(self, request, instance): - interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( + def get_components(self, request, instance): + return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), - Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', 'cable', '_path__destination', 'tags', + Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)) ) - 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): - queryset = Device.objects.all() +class DeviceFrontPortsView(DeviceComponentsView): + model = FrontPort + table = tables.DeviceFrontPortTable 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 { - 'frontport_table': frontport_table, - 'active_tab': 'front-ports', - } - - -class DeviceRearPortsView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceRearPortsView(DeviceComponentsView): + model = RearPort + table = tables.DeviceRearPortTable 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 { - 'rearport_table': rearport_table, - 'active_tab': 'rear-ports', - } - - -class DeviceDeviceBaysView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceDeviceBaysView(DeviceComponentsView): + model = DeviceBay + table = tables.DeviceDeviceBayTable 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 { - 'devicebay_table': devicebay_table, - 'active_tab': 'device-bays', - } - - -class DeviceInventoryView(generic.ObjectView): - queryset = Device.objects.all() +class DeviceInventoryView(DeviceComponentsView): + model = InventoryItem + table = tables.DeviceInventoryItemTable 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): additional_permissions = ['dcim.napalm_read_device'] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25fd32f0d..af8d904f4 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -15,6 +15,7 @@ from .models import * __all__ = ( 'ConfigContextFilterSet', 'ContentTypeFilterSet', + 'CustomFieldFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', @@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet): ] -class CustomFieldFilterSet(django_filters.FilterSet): +class CustomFieldFilterSet(BaseFilterSet): content_types = ContentTypeFilter() class Meta: diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9f3139793..2b221fdab 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,14 +3,12 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model @@ -67,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer): # RIRs/aggregates # -class RIRSerializer(OrganizationalModelSerializer): +class RIRSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR 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', ] @@ -98,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer): # VLANs # -class RoleSerializer(OrganizationalModelSerializer): +class RoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -106,43 +104,32 @@ class RoleSerializer(OrganizationalModelSerializer): class Meta: model = Role fields = [ - 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', - 'prefix_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'prefix_count', 'vlan_count', ] -class VLANGroupSerializer(OrganizationalModelSerializer): +class VLANGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') scope_type = ContentTypeField( queryset=ContentType.objects.filter( 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) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', - 'created', 'last_updated', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'vlan_count', ] 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): if obj.scope_id is None: return None @@ -155,7 +142,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): class VLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') 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) status = ChoiceField(choices=VLANStatusChoices, required=False) 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', '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 # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..a043bd88c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') - ) + ).prefetch_related('tags') serializer_class = serializers.RIRSerializer filterset_class = filtersets.RIRFilterSet @@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), vlan_count=count_related(VLAN, 'role') - ) + ).prefetch_related('tags') serializer_class = serializers.RoleSerializer filterset_class = filtersets.RoleFilterSet @@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..43bf40f88 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -71,7 +71,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ] -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB } -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a9c8a0910 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RIRForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RIR 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): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Role fields = [ - 'name', 'slug', 'weight', 'description', + 'name', 'slug', 'weight', 'description', 'tags', ] @@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', + 'clustergroup', 'cluster', 'tags', ] fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description')), + ('VLAN Group', ('name', 'slug', 'description', 'tags')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) widgets = { diff --git a/netbox/ipam/migrations/0051_extend_tag_support.py b/netbox/ipam/migrations/0051_extend_tag_support.py new file mode 100644 index 000000000..ea31a6645 --- /dev/null +++ b/netbox/ipam/migrations/0051_extend_tag_support.py @@ -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'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..514e87a62 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -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): """ 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) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..14eaa7ccc 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -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): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 485e4a123..a2a0c67b1 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -85,11 +85,14 @@ class RIRTable(BaseTable): url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) + tags = TagColumn( + url_name='ipam:rir_list' + ) actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): 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') @@ -144,11 +147,14 @@ class RoleTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:role_list' + ) actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): 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') @@ -260,11 +266,16 @@ class IPRangeTable(BaseTable): linkify=True ) tenant = TenantColumn() + utilization = UtilizationColumn( + accessor='utilization', + orderable=False + ) class Meta(BaseTable.Meta): model = IPRange fields = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', + 'utilization', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index fd1e92be8..4c0d5d729 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:vlangroup_list' + ) actions = ButtonsColumn( model=VLANGroup, prepend_template=VLANGROUP_ADD_VLAN @@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): 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') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..5440efcb6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -104,11 +104,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RIR(name='RIR 3', slug='rir-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'RIR X', 'slug': 'rir-x', 'is_private': True, 'description': 'A new RIR', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -177,11 +180,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Role(name='Role 3', slug='role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Role X', 'slug': 'role-x', 'weight': 200, 'description': 'A new role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -384,10 +390,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', 'description': 'A new VLAN group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index d17751e25..9f51d475d 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer): # Base model serializers # -class OrganizationalModelSerializer(CustomFieldModelSerializer): - """ - Adds support for custom fields. - """ - pass - - class PrimaryModelSerializer(CustomFieldModelSerializer): """ Adds support for custom fields and tags. @@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): 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) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 181b9a0c6..7d71bd1fb 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -41,6 +41,7 @@ class ObjectType( class OrganizationalObjectType( ChangelogMixin, CustomFieldsMixin, + TagsMixin, BaseObjectType ): """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 317548921..95cea6a93 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model): 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 @@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): 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. """ @@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, object_id_field='assigned_object_id', content_type_field='assigned_object_type' ) - tags = TaggableManager( - through='extras.TaggedItem' - ) class Meta: 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 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 any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 7f64a2df8..993c5e171 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu( 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'), + ), + ), ), ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e41c77d1d..6381435f2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.8-dev' +VERSION = '3.0.9-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 3568204fe..2c033e760 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -137,7 +137,7 @@ class HomeView(View): release_version, release_url = latest_release if release_version > version.parse(settings.VERSION): new_release = { - 'version': str(latest_release), + 'version': str(release_version), 'url': release_url, } diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 4baf2e0e9..75e978e2a 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -282,11 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): messages.success(request, mark_safe(msg)) 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 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) diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index 7fac1012a..2b3934742 100644 --- a/netbox/project-static/dist/lldp.js +++ b/netbox/project-static/dist/lldp.js @@ -1,5 +1,5 @@ -(()=>{var gr=Object.create;var le=Object.defineProperty,_r=Object.defineProperties,Er=Object.getOwnPropertyDescriptor,vr=Object.getOwnPropertyDescriptors,yr=Object.getOwnPropertyNames,mn=Object.getOwnPropertySymbols,Tr=Object.getPrototypeOf,gn=Object.prototype.hasOwnProperty,br=Object.prototype.propertyIsEnumerable;var _n=(i,t,e)=>t in i?le(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e,O=(i,t)=>{for(var e in t||(t={}))gn.call(t,e)&&_n(i,e,t[e]);if(mn)for(var e of mn(t))br.call(t,e)&&_n(i,e,t[e]);return i},je=(i,t)=>_r(i,vr(t)),En=i=>le(i,"__esModule",{value:!0});var mt=(i,t)=>()=>(t||i((t={exports:{}}).exports,t),t.exports),Ar=(i,t)=>{En(i);for(var e in t)le(i,e,{get:t[e],enumerable:!0})},Sr=(i,t,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of yr(t))!gn.call(i,n)&&n!=="default"&&le(i,n,{get:()=>t[n],enumerable:!(e=Er(t,n))||e.enumerable});return i},vn=i=>Sr(En(le(i!=null?gr(Tr(i)):{},"default",i&&i.__esModule&&"default"in i?{get:()=>i.default,enumerable:!0}:{value:i,enumerable:!0})),i);var bi=(i,t,e)=>new Promise((n,o)=>{var r=u=>{try{l(e.next(u))}catch(p){o(p)}},s=u=>{try{l(e.throw(u))}catch(p){o(p)}},l=u=>u.done?n(u.value):Promise.resolve(u.value).then(r,s);l((e=e.apply(i,t)).next())});var on=mt((er,li)=>{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof li=="object"&&li.exports?li.exports=t():i.EvEmitter=t()})(typeof window!="undefined"?window:er,function(){"use strict";function i(){}var t=i.prototype;return t.on=function(e,n){if(!(!e||!n)){var o=this._events=this._events||{},r=o[e]=o[e]||[];return r.indexOf(n)==-1&&r.push(n),this}},t.once=function(e,n){if(!(!e||!n)){this.on(e,n);var o=this._onceEvents=this._onceEvents||{},r=o[e]=o[e]||{};return r[n]=!0,this}},t.off=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){var r=o.indexOf(n);return r!=-1&&o.splice(r,1),this}},t.emitEvent=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){o=o.slice(0),n=n||[];for(var r=this._onceEvents&&this._onceEvents[e],s=0;s{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof ci=="object"&&ci.exports?ci.exports=t():i.getSize=t()})(window,function(){"use strict";function t(d){var v=parseFloat(d),E=d.indexOf("%")==-1&&!isNaN(v);return E&&v}function e(){}var n=typeof console=="undefined"?e:function(d){console.error(d)},o=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"],r=o.length;function s(){for(var d={width:0,height:0,innerWidth:0,innerHeight:0,outerWidth:0,outerHeight:0},v=0;v