diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 1d84fea24..496e4f7fa 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -156,9 +156,13 @@ direction = ChoiceVar(choices=CHOICES) ### ObjectVar -A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type. +A NetBox object of a particular type, identified by the associated queryset. Most models will utilize the REST API to retrieve available options: Note that any filtering on the queryset in this case has no effect. -* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) +* `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model + +### MultiObjectVar + +Similar to `ObjectVar`, but allows for the selection of multiple objects. ### FileVar @@ -222,10 +226,7 @@ class NewBranchScript(Script): ) switch_model = ObjectVar( description="Access switch model", - queryset = DeviceType.objects.filter( - manufacturer__name='Cisco', - model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T'] - ) + queryset = DeviceType.objects.all() ) def run(self, data, commit): diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 8ef2b4b21..69939d6e5 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -382,6 +382,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + +Default: 22 + +Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_WIDTH + +Default: 220 + +Default width (in pixels) of a unit within a rack elevation. + +--- + ## REMOTE_AUTH_ENABLED Default: `False` diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 19f6ca023..d95edccf9 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -44,11 +44,7 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th Extend the model's API serializer in `.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model. -## 6. Add choices to API view - -If the new field has static choices, add it to the `FieldChoicesViewSet` for the app. - -## 7. Add field to forms +## 6. Add field to forms Extend any forms to include the new field as appropriate. Common forms include: @@ -57,19 +53,19 @@ Extend any forms to include the new field as appropriate. Common forms include: * **CSV import** - The form used when bulk importing objects in CSV format * **Filter** - Displays the options available for filtering a list of objects (both UI and API) -## 8. Extend object filter set +## 7. Extend object filter set If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. -## 9. Add column to object table +## 8. Add column to object table If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column. -## 10. Update the UI templates +## 9. Update the UI templates Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. -## 11. Create/extend test cases +## 10. Create/extend test cases Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including: diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index ca264806b..e13e06b62 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,11 +1,20 @@ # NetBox v2.8 -## v2.8.7 (FUTURE) +## v2.8.7 (2020-07-02) + +### Enhancements + +* [#4796](https://github.com/netbox-community/netbox/issues/4796) - Introduce configuration parameters for default rack elevation size +* [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results ### Bug Fixes +* [#4695](https://github.com/netbox-community/netbox/issues/4695) - Expose cable termination type choices in OpenAPI spec +* [#4708](https://github.com/netbox-community/netbox/issues/4708) - Relax connection constraints for multi-position rear ports * [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified +* [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects * [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint +* [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays * [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates --- diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index ee259a67a..c0d51afc2 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,4 +1,4 @@ -# NetBox v2.8 +# NetBox v2.9 ## v2.9.0 (FUTURE) @@ -10,9 +10,15 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Enhancements +* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model * [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations +* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components +* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports +* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates +* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports +* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates ### Configuration Changes @@ -33,10 +39,17 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * The `tags` field of an object now includes a more complete representation of each tag, rather than just its name. * A `label` field has been added to all device components and component templates. +* The IP address model now uses a generic foreign key to refer to the assigned interface. The `interface` field on the serializer has been replaced with `assigned_object_type` and `assigned_object_id` for write operations. If one exists, the assigned interface is available as `assigned_object`. +* The serialized representation of a virtual machine interface now includes only relevant fields: `type`, `lag`, `mgmt_only`, `connected_endpoint_type`, `connected_endpoint`, and `cable` are no longer included. +* dcim.VirtualChassis: Added a mandatory `name` field +* An optional `description` field has been added to all device component templates ### Other Changes +* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly. * The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. * The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens. * Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead). * Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`). +* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add` rather than via the devices list. +* A name is required when creating a virtual chassis. diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 363392a4d..1575a181b 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,4 +1,4 @@ -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -28,8 +28,8 @@ class ProviderViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular provider. """ - provider = get_object_or_404(Provider, pk=pk) - queryset = Graph.objects.filter(type__model='provider') + provider = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='provider') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) return Response(serializer.data) @@ -52,7 +52,10 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device' + Prefetch('terminations', queryset=CircuitTermination.objects.unrestricted().prefetch_related( + 'site', 'connected_endpoint__device' + )), + 'type', 'tenant', 'provider', ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index dcf1c5118..72d1c0974 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -239,7 +239,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self.STATUS_CLASS_MAP.get(self.status) def _get_termination(self, side): - for ct in self.terminations.all(): + for ct in self.terminations.unrestricted(): if ct.term_side == side: return ct return None diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db21400..071b7af20 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -10,7 +10,7 @@ def update_circuit(instance, **kwargs): """ When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) + circuits = Circuit.objects.unrestricted().filter(pk=instance.circuit_id) time = timezone.now() for circuit in circuits: circuit.last_updated = time diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ea17031a1..ce3368f31 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,19 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import Circuit, CircuitType, Provider -CIRCUITTYPE_ACTIONS = """ - - - -{% if perms.circuit.change_circuittype %} - -{% endif %} -""" - STATUS_LABEL = """ {{ record.get_status_display }} """ @@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = tables.TemplateColumn( - template_code=CIRCUITTYPE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(CircuitType, pk_field='slug') class Meta(BaseTable.Meta): model = CircuitType diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 4e062cc1a..f887db29e 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): """ Test retrieval of Graphs assigned to Providers. """ - provider = self.model.objects.first() + provider = self.model.objects.unrestricted().first() ct = ContentType.objects.get(app_label='circuits', model='provider') graphs = ( Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'), diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1c0f0715b..86ea55fa8 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f100dd3c7..e2dc80816 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -60,19 +60,16 @@ class ProviderEditView(ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' - default_return_url = 'circuits:provider_list' class ProviderDeleteView(ObjectDeleteView): queryset = Provider.objects.all() - default_return_url = 'circuits:provider_list' class ProviderBulkImportView(BulkImportView): queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable - default_return_url = 'circuits:provider_list' class ProviderBulkEditView(BulkEditView): @@ -80,14 +77,12 @@ class ProviderBulkEditView(BulkEditView): filterset = filters.ProviderFilterSet table = tables.ProviderTable form = forms.ProviderBulkEditForm - default_return_url = 'circuits:provider_list' class ProviderBulkDeleteView(BulkDeleteView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable - default_return_url = 'circuits:provider_list' # @@ -102,20 +97,21 @@ class CircuitTypeListView(ObjectListView): class CircuitTypeEditView(ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm - default_return_url = 'circuits:circuittype_list' + + +class CircuitTypeDeleteView(ObjectDeleteView): + queryset = CircuitType.objects.all() class CircuitTypeBulkImportView(BulkImportView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' class CircuitTypeBulkDeleteView(BulkDeleteView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' # @@ -165,19 +161,16 @@ class CircuitEditView(ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' - default_return_url = 'circuits:circuit_list' class CircuitDeleteView(ObjectDeleteView): queryset = Circuit.objects.all() - default_return_url = 'circuits:circuit_list' class CircuitBulkImportView(BulkImportView): queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' class CircuitBulkEditView(BulkEditView): @@ -185,14 +178,12 @@ class CircuitBulkEditView(BulkEditView): filterset = filters.CircuitFilterSet table = tables.CircuitTable form = forms.CircuitBulkEditForm - default_return_url = 'circuits:circuit_list' class CircuitBulkDeleteView(BulkDeleteView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' class CircuitSwapTerminations(ObjectEditView): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 83fcd7a2a..5d9380c00 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -332,7 +332,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): class Meta: model = models.VirtualChassis - fields = ['id', 'url', 'master', 'member_count'] + fields = ['id', 'name', 'url', 'master', 'member_count'] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c684b8041..6838c3987 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,4 @@ +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 @@ -183,10 +184,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH ) unit_height = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT @@ -245,7 +246,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'description'] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -258,7 +259,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'description'] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -271,7 +272,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -292,7 +293,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -301,7 +302,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -310,7 +311,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'positions'] + fields = ['id', 'device_type', 'name', 'type', 'positions', 'description'] class FrontPortTemplateSerializer(ValidatedModelSerializer): @@ -320,7 +321,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position'] + fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -328,7 +329,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'device_type', 'name', 'label'] + fields = ['id', 'device_type', 'name', 'label', 'description'] # @@ -694,12 +695,12 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer): - master = NestedDeviceSerializer() + master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain', 'tags', 'member_count'] + fields = ['id', 'name', 'domain', 'master', 'tags', 'member_count'] # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 324edcb49..6de2a5982 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -29,6 +29,7 @@ from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery +from utilities.metadata import ContentTypeMetadata from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -43,7 +44,7 @@ class CableTraceMixin(object): """ Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination). """ - obj = get_object_or_404(self.queryset.model, pk=pk) + obj = get_object_or_404(self.queryset, pk=pk) # Initialize the path array path = [] @@ -103,8 +104,8 @@ class SiteViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular site. """ - site = get_object_or_404(Site, pk=pk) - queryset = Graph.objects.filter(type__model='site') + site = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='site') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) return Response(serializer.data) @@ -156,7 +157,7 @@ class RackViewSet(CustomFieldModelViewSet): """ Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG. """ - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(self.queryset, pk=pk) serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET) if not serializer.is_valid(): return Response(serializer.errors, 400) @@ -226,7 +227,7 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate( + queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( device_count=Count('instances') ) serializer_class = serializers.DeviceTypeSerializer @@ -347,8 +348,8 @@ class DeviceViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular Device. """ - device = get_object_or_404(Device, pk=pk) - queryset = Graph.objects.filter(type__model='device') + device = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='device') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) return Response(serializer.data) @@ -369,7 +370,7 @@ class DeviceViewSet(CustomFieldModelViewSet): """ Execute a NAPALM method on a Device """ - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) if not device.primary_ip: raise ServiceUnavailable("This device does not have a primary IP address configured.") if device.platform is None: @@ -496,8 +497,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): """ A convenience method for rendering graphs for a particular interface. """ - interface = get_object_or_404(Interface, pk=pk) - queryset = Graph.objects.filter(type__model='interface') + interface = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='interface') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) return Response(serializer.data) @@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): # class CableViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -655,7 +657,11 @@ class ConnectedDeviceViewSet(ViewSet): raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') # Determine local interface from peer interface's connection - peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) + peer_interface = get_object_or_404( + Interface.objects.unrestricted(), + device__name=peer_device_name, + name=peer_interface_name + ) local_interface = peer_interface._connected_interface if local_interface is None: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 479563093..59f30a206 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -260,6 +260,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_520P = 'nema-5-20p' TYPE_NEMA_530P = 'nema-5-30p' @@ -268,16 +269,27 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_620P = 'nema-6-20p' TYPE_NEMA_630P = 'nema-6-30p' TYPE_NEMA_650P = 'nema-6-50p' + TYPE_NEMA_1030P = 'nema-10-30p' + TYPE_NEMA_1050P = 'nema-10-50p' + TYPE_NEMA_1420P = 'nema-14-20p' + TYPE_NEMA_1430P = 'nema-14-30p' + TYPE_NEMA_1450P = 'nema-14-50p' + TYPE_NEMA_1460P = 'nema-14-60p' # NEMA locking + TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' TYPE_NEMA_L520P = 'nema-l5-20p' TYPE_NEMA_L530P = 'nema-l5-30p' - TYPE_NEMA_L615P = 'nema-l5-50p' + TYPE_NEMA_L550P = 'nema-l5-50p' + TYPE_NEMA_L615P = 'nema-l6-15p' TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L650P = 'nema-l6-50p' + TYPE_NEMA_L1030P = 'nema-l10-30p' TYPE_NEMA_L1420P = 'nema-l14-20p' TYPE_NEMA_L1430P = 'nema-l14-30p' + TYPE_NEMA_L1450P = 'nema-l14-50p' + TYPE_NEMA_L1460P = 'nema-l14-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' # California style @@ -324,6 +336,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_530P, 'NEMA 5-30P'), @@ -332,17 +345,28 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_620P, 'NEMA 6-20P'), (TYPE_NEMA_630P, 'NEMA 6-30P'), (TYPE_NEMA_650P, 'NEMA 6-50P'), + (TYPE_NEMA_1030P, 'NEMA 10-30P'), + (TYPE_NEMA_1050P, 'NEMA 10-50P'), + (TYPE_NEMA_1420P, 'NEMA 14-20P'), + (TYPE_NEMA_1430P, 'NEMA 14-30P'), + (TYPE_NEMA_1450P, 'NEMA 14-50P'), + (TYPE_NEMA_1460P, 'NEMA 14-60P'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115P, 'NEMA L1-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L530P, 'NEMA L5-30P'), + (TYPE_NEMA_L550P, 'NEMA L5-50P'), (TYPE_NEMA_L615P, 'NEMA L6-15P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'), + (TYPE_NEMA_L1030P, 'NEMA L10-30P'), (TYPE_NEMA_L1420P, 'NEMA L14-20P'), (TYPE_NEMA_L1430P, 'NEMA L14-30P'), + (TYPE_NEMA_L1450P, 'NEMA L14-50P'), + (TYPE_NEMA_L1460P, 'NEMA L14-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), @@ -397,6 +421,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_520R = 'nema-5-20r' TYPE_NEMA_530R = 'nema-5-30r' @@ -405,16 +430,27 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_620R = 'nema-6-20r' TYPE_NEMA_630R = 'nema-6-30r' TYPE_NEMA_650R = 'nema-6-50r' + TYPE_NEMA_1030R = 'nema-10-30r' + TYPE_NEMA_1050R = 'nema-10-50r' + TYPE_NEMA_1420R = 'nema-14-20r' + TYPE_NEMA_1430R = 'nema-14-30r' + TYPE_NEMA_1450R = 'nema-14-50r' + TYPE_NEMA_1460R = 'nema-14-60r' # NEMA locking + TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' TYPE_NEMA_L520R = 'nema-l5-20r' TYPE_NEMA_L530R = 'nema-l5-30r' - TYPE_NEMA_L615R = 'nema-l5-50r' + TYPE_NEMA_L550R = 'nema-l5-50r' + TYPE_NEMA_L615R = 'nema-l6-15r' TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L650R = 'nema-l6-50r' + TYPE_NEMA_L1030R = 'nema-l10-30r' TYPE_NEMA_L1420R = 'nema-l14-20r' TYPE_NEMA_L1430R = 'nema-l14-30r' + TYPE_NEMA_L1450R = 'nema-l14-50r' + TYPE_NEMA_L1460R = 'nema-l14-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' # California style @@ -462,6 +498,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_530R, 'NEMA 5-30R'), @@ -470,17 +507,28 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_620R, 'NEMA 6-20R'), (TYPE_NEMA_630R, 'NEMA 6-30R'), (TYPE_NEMA_650R, 'NEMA 6-50R'), + (TYPE_NEMA_1030R, 'NEMA 10-30R'), + (TYPE_NEMA_1050R, 'NEMA 10-50R'), + (TYPE_NEMA_1420R, 'NEMA 14-20R'), + (TYPE_NEMA_1430R, 'NEMA 14-30R'), + (TYPE_NEMA_1450R, 'NEMA 14-50R'), + (TYPE_NEMA_1460R, 'NEMA 14-60R'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115R, 'NEMA L1-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L530R, 'NEMA L5-30R'), + (TYPE_NEMA_L550R, 'NEMA L5-50R'), (TYPE_NEMA_L615R, 'NEMA L6-15R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'), + (TYPE_NEMA_L1030R, 'NEMA L10-30R'), (TYPE_NEMA_L1420R, 'NEMA L14-20R'), (TYPE_NEMA_L1430R, 'NEMA L14-30R'), + (TYPE_NEMA_L1450R, 'NEMA L14-50R'), + (TYPE_NEMA_L1460R, 'NEMA L14-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index f938b6f14..66768515c 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 -RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220 -RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2109f0784..281818895 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,12 +23,11 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, - NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Cluster, ClusterGroup, VirtualMachine +from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * from .models import ( @@ -140,38 +139,14 @@ class LabeledComponentForm(BootstrapMixin, forms.Form): def clean(self): # Validate that the number of components being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} components, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }, code='label_pattern_mismatch') - - -class BulkRenameForm(forms.Form): - """ - An extendable form to be used for renaming device components in bulk. - """ - find = forms.CharField() - replace = forms.CharField() - use_regex = forms.BooleanField( - required=False, - initial=True, - label='Use regular expressions' - ) - - def clean(self): - - # Validate regular expression in "find" field - if self.cleaned_data['use_regex']: - try: - re.compile(self.cleaned_data['find']) - except re.error: + if self.cleaned_data['label_pattern']: + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if name_pattern_count != label_pattern_count: raise forms.ValidationError({ - 'find': "Invalid regular expression" - }) + 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' + f'{label_pattern_count} labels will be generated. These counts must match.' + }, code='label_pattern_mismatch') # @@ -1077,6 +1052,9 @@ class ComponentTemplateCreateForm(LabeledComponentForm): display_field='model' ) ) + description = forms.CharField( + required=False + ) class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1084,7 +1062,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1103,6 +1081,10 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=ConsolePortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -1110,7 +1092,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type',) + nullable_fields = ('label', 'type', 'description') class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1118,7 +1100,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1137,14 +1119,21 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=ConsoleServerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type',) + nullable_fields = ('label', 'type', 'description') class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1152,7 +1141,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1181,6 +1170,10 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=PowerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -1196,9 +1189,12 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, help_text="Allocated power draw (watts)" ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type', 'maximum_draw', 'allocated_draw') + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1206,7 +1202,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1261,6 +1257,10 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): disabled=True, widget=forms.HiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -1275,9 +1275,12 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type', 'power_port', 'feed_leg') + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1296,7 +1299,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', + 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1320,6 +1323,10 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(InterfaceTypeChoices), required=False, @@ -1330,9 +1337,12 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): widget=BulkEditNullBooleanSelect, label='Management only' ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = [] + nullable_fields = ('label', 'description') class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1340,7 +1350,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1425,9 +1435,12 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = () + nullable_fields = ('description',) class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1435,7 +1448,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', + 'device_type', 'name', 'type', 'positions', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1466,9 +1479,12 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = () + nullable_fields = ('description',) class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1476,7 +1492,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate fields = [ - 'device_type', 'name', 'label', + 'device_type', 'name', 'label', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1484,18 +1500,26 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - pass + description = forms.CharField( + required=False + ) -# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet -# class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): -# pk = forms.ModelMultipleChoiceField( -# queryset=FrontPortTemplate.objects.all(), -# widget=forms.MultipleHiddenInput() -# ) -# -# class Meta: -# nullable_fields = () +class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'description') # @@ -1816,21 +1840,25 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ip_choices = [(None, '---------')] # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member - interface_ids = self.instance.vc_interfaces.values('pk') + interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True) # Collect interface IPs - interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface_id__in=interface_ids - ) + interface_ips = IPAddress.objects.filter( + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') if interface_ips: - ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, nat_inside__interface__in=interface_ids - ) + address__family=family, + nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), + nat_inside__assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') if nat_ips: - ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips] ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices @@ -2292,14 +2320,14 @@ class ConsolePortCreateForm(ComponentCreateForm): class ConsolePortBulkCreateForm( - form_from_model(ConsolePort, ['type', 'description', 'tags']), + form_from_model(ConsolePort, ['label', 'type', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['type', 'description']), + form_from_model(ConsolePort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2310,9 +2338,7 @@ class ConsolePortBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) + nullable_fields = ('label', 'description') class ConsolePortCSVForm(CSVModelForm): @@ -2374,14 +2400,14 @@ class ConsoleServerPortCreateForm(ComponentCreateForm): class ConsoleServerPortBulkCreateForm( - form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), + form_from_model(ConsoleServerPort, ['label', 'type', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['type', 'description']), + form_from_model(ConsoleServerPort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2392,23 +2418,7 @@ class ConsoleServerPortBulkEditForm( ) class Meta: - nullable_fields = [ - 'description', - ] - - -class ConsoleServerPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - -class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) + nullable_fields = ('label', 'description') class ConsoleServerPortCSVForm(CSVModelForm): @@ -2480,14 +2490,14 @@ class PowerPortCreateForm(ComponentCreateForm): class PowerPortBulkCreateForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class PowerPortBulkEditForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2498,9 +2508,7 @@ class PowerPortBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) + nullable_fields = ('label', 'description') class PowerPortCSVForm(CSVModelForm): @@ -2592,14 +2600,14 @@ class PowerOutletCreateForm(ComponentCreateForm): class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2616,9 +2624,7 @@ class PowerOutletBulkEditForm( ) class Meta: - nullable_fields = [ - 'type', 'feed_leg', 'power_port', 'description', - ] + nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2632,20 +2638,6 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - -class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class PowerOutletCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -2863,14 +2855,16 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description']), + form_from_model(Interface, ['label', 'type', 'enabled', 'mtu', 'mgmt_only', 'description']), DeviceBulkAddComponentForm ): pass class InterfaceBulkEditForm( - form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']), + form_from_model(Interface, [ + 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' + ]), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2909,9 +2903,9 @@ class InterfaceBulkEditForm( ) class Meta: - nullable_fields = [ - 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ] + nullable_fields = ( + 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2944,29 +2938,9 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - -class InterfaceBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class InterfaceCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), - required=False, - to_field_name='name' - ) - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, to_field_name='name' ) lag = CSVModelChoiceField( @@ -3143,20 +3117,6 @@ class FrontPortBulkEditForm( ] -class FrontPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - -class FrontPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class FrontPortCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3273,20 +3233,6 @@ class RearPortBulkEditForm( ] -class RearPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - -class RearPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class RearPortCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3356,7 +3302,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkCreateForm( - form_from_model(DeviceBay, ['description', 'tags']), + form_from_model(DeviceBay, ['label', 'description', 'tags']), DeviceBulkAddComponentForm ): tags = DynamicModelMultipleChoiceField( @@ -3366,7 +3312,7 @@ class DeviceBayBulkCreateForm( class DeviceBayBulkEditForm( - form_from_model(DeviceBay, ['description']), + form_from_model(DeviceBay, ['label', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -3377,16 +3323,7 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) - - -class DeviceBayBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) + nullable_fields = ('label', 'description') class DeviceBayCSVForm(CSVModelForm): @@ -3435,6 +3372,165 @@ class DeviceBayCSVForm(CSVModelForm): self.fields['installed_device'].queryset = Interface.objects.none() +# +# Inventory items +# + +class InventoryItemForm(BootstrapMixin, forms.ModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer') + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = InventoryItem + fields = [ + 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', + ] + + +class InventoryItemCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer') + ) + name_pattern = ExpandableNameField( + label='Name' + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + serial = forms.CharField( + max_length=50, + required=False, + ) + asset_tag = forms.CharField( + max_length=50, + required=False, + ) + description = forms.CharField( + max_length=100, + required=False + ) + + +class InventoryItemCSVForm(CSVModelForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = InventoryItem + fields = InventoryItem.csv_headers + + +class InventoryItemBulkCreateForm( + form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'tags']), + DeviceBulkAddComponentForm +): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + +class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItem.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'manufacturer', 'part_id', 'description', + ] + + +class InventoryItemFilterForm(BootstrapMixin, forms.Form): + model = InventoryItem + q = forms.CharField( + required=False, + label='Search' + ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) + site = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + value_field="slug", + filter_for={ + 'device_id': 'site' + } + ) + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device' + ) + manufacturer = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='slug', + required=False, + widget=APISelect( + value_field="slug", + ) + ) + discovered = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + # # Cables # @@ -3982,155 +4078,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): ) -# -# Inventory items -# - -class InventoryItemForm(BootstrapMixin, forms.ModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer') - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = InventoryItem - fields = [ - 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', - ] - - -class InventoryItemCreateForm(BootstrapMixin, forms.Form): - device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer') - ) - name_pattern = ExpandableNameField( - label='Name' - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - serial = forms.CharField( - max_length=50, - required=False, - ) - asset_tag = forms.CharField( - max_length=50, - required=False, - ) - description = forms.CharField( - max_length=100, - required=False - ) - - -class InventoryItemCSVForm(CSVModelForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - manufacturer = CSVModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name', - required=False - ) - - class Meta: - model = InventoryItem - fields = InventoryItem.csv_headers - - -class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItem.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'manufacturer', 'part_id', 'description', - ] - - -class InventoryItemFilterForm(BootstrapMixin, forms.Form): - model = InventoryItem - q = forms.CharField( - required=False, - label='Search' - ) - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'site': 'region' - } - ) - ) - site = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'device_id': 'site' - } - ) - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - label='Device' - ) - manufacturer = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='slug', - required=False, - widget=APISelect( - value_field="slug", - ) - ) - discovered = forms.NullBooleanField( - required=False, - widget=StaticSelect2( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - # # Virtual chassis # @@ -4142,7 +4089,38 @@ class DeviceSelectionForm(forms.Form): ) -class VirtualChassisForm(BootstrapMixin, forms.ModelForm): +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'rack': 'site_id', + 'members': 'site_id', + } + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'members': 'rack_id' + }, + attrs={ + 'nullable': 'true', + } + ) + ) + members = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + ) + initial_position = forms.IntegerField( + initial=1, + required=False, + help_text='Position of the first member device. Increases by one for each additional member.' + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -4151,12 +4129,47 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = [ - 'master', 'domain', 'tags', + 'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags', + ] + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Assign VC members + if instance.pk: + initial_position = self.cleaned_data.get('initial_position') or 1 + for i, member in enumerate(self.cleaned_data['members'], start=initial_position): + member.virtual_chassis = instance + member.vc_position = i + member.save() + + return instance + + +class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VirtualChassis + fields = [ + 'name', 'domain', 'master', 'tags', ] widgets = { 'master': SelectWithPK(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance) + class BaseVCMemberFormSet(forms.BaseModelFormSet): @@ -4249,7 +4262,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): device = self.cleaned_data['device'] if device.virtual_chassis is not None: raise forms.ValidationError( - "Device {} is already assigned to a virtual chassis.".format(device) + f"Device {device} is already assigned to a virtual chassis." ) return device @@ -4268,6 +4281,19 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm nullable_fields = ['domain'] +class VirtualChassisCSVForm(CSVModelForm): + master = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Master device' + ) + + class Meta: + model = VirtualChassis + fields = VirtualChassis.csv_headers + + class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualChassis q = forms.CharField( diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index 4e3c941a1..925694958 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -79,42 +79,42 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebay', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='inventoryitem', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlet', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleports, diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index 24fe98e94..70acd3189 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -75,37 +75,37 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebaytemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlettemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleporttemplates, diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 6225a9b73..2d6be72c8 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -43,17 +43,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), ), migrations.AddField( model_name='rack', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='site', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_sites, diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py index f1622f504..7b2663c95 100644 --- a/netbox/dcim/migrations/0096_interface_ordering.py +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -35,12 +35,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.AddField( model_name='interfacetemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.RunPython( code=naturalize_interfacetemplates, diff --git a/netbox/dcim/migrations/0109_interface_remove_vm.py b/netbox/dcim/migrations/0109_interface_remove_vm.py new file mode 100644 index 000000000..97a84a43e --- /dev/null +++ b/netbox/dcim/migrations/0109_interface_remove_vm.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-06-22 16:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0108_add_tags'), + ('virtualization', '0016_replicate_interfaces'), + ] + + operations = [ + migrations.RemoveField( + model_name='interface', + name='virtual_machine', + ), + ] diff --git a/netbox/dcim/migrations/0110_virtualchassis_name.py b/netbox/dcim/migrations/0110_virtualchassis_name.py new file mode 100644 index 000000000..e8455d6fe --- /dev/null +++ b/netbox/dcim/migrations/0110_virtualchassis_name.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def copy_master_name(apps, schema_editor): + """ + Copy the master device's name to the VirtualChassis. + """ + VirtualChassis = apps.get_model('dcim', 'VirtualChassis') + + for vc in VirtualChassis.objects.prefetch_related('master'): + name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}' + VirtualChassis.objects.filter(pk=vc.pk).update(name=name) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0109_interface_remove_vm'), + ] + + operations = [ + migrations.AlterModelOptions( + name='virtualchassis', + options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'}, + ), + migrations.AddField( + model_name='virtualchassis', + name='name', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AlterField( + model_name='virtualchassis', + name='master', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'), + ), + migrations.RunPython( + code=copy_master_name, + reverse_code=migrations.RunPython.noop + ), + migrations.AlterField( + model_name='virtualchassis', + name='name', + field=models.CharField(max_length=64), + ), + ] diff --git a/netbox/dcim/migrations/0111_component_template_description.py b/netbox/dcim/migrations/0111_component_template_description.py new file mode 100644 index 000000000..3040f586c --- /dev/null +++ b/netbox/dcim/migrations/0111_component_template_description.py @@ -0,0 +1,53 @@ +# Generated by Django 3.0.6 on 2020-06-30 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0110_virtualchassis_name'), + ] + + operations = [ + migrations.AddField( + model_name='consoleporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='frontporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='interfacetemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rearporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 236979b4a..1aeedf1e5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -35,11 +35,12 @@ from .device_component_templates import ( PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from .device_components import ( - CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, - PowerPort, RearPort, + BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, + PowerOutlet, PowerPort, RearPort, ) __all__ = ( + 'BaseInterface', 'Cable', 'CableTermination', 'ConsolePort', @@ -579,7 +580,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel): if self.pk: # Validate that Rack is tall enough to house the installed Devices - top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first() + top_device = Device.objects.unrestricted().filter( + rack=self + ).exclude( + position__isnull=True + ).order_by('-position').first() if top_device: min_height = top_device.position + top_device.device_type.u_height - 1 if self.u_height < min_height: @@ -600,13 +605,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Record the original site assignment for this rack. _site_id = None if self.pk: - _site_id = Rack.objects.get(pk=self.pk).site_id + _site_id = Rack.objects.unrestricted().get(pk=self.pk).site_id super().save(*args, **kwargs) # Update racked devices if the assigned Site has been changed. if _site_id is not None and self.site_id != _site_id: - devices = Device.objects.filter(rack=self) + devices = Device.objects.unrestricted().filter(rack=self) for device in devices: device.site = self.site device.save() @@ -668,7 +673,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Add devices to rack units list if self.pk: - queryset = Device.objects.prefetch_related( + queryset = Device.objects.unrestricted().prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -744,8 +749,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_elevation_svg( self, face=DeviceFaceChoices.FACE_FRONT, - unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, + unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None @@ -1124,7 +1129,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. if self.pk and self.u_height > self._original_u_height: - for d in Device.objects.filter(device_type=self, position__isnull=False): + for d in Device.objects.unrestricted().filter(device_type=self, position__isnull=False): face_required = None if self.is_full_depth else d.face u_available = d.rack.get_available_units( u_height=self.u_height, @@ -1139,7 +1144,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. elif self.pk and self._original_u_height > 0 and self.u_height == 0: - racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count() + racked_instance_count = Device.objects.unrestricted().filter( + device_type=self, + position__isnull=False + ).count() if racked_instance_count: url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}" raise ValidationError({ @@ -1492,7 +1500,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. if self.name and self.tenant is None: - if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True): + if Device.objects.unrestricted().exclude(pk=self.pk).filter( + name=self.name, + site=self.site, + tenant__isnull=True + ): raise ValidationError({ 'name': 'A device with this name already exists.' }) @@ -1571,9 +1583,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): raise ValidationError({ 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address." }) - if self.primary_ip4.interface in vc_interfaces: + if self.primary_ip4.assigned_object in vc_interfaces: pass - elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces: + elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces: pass else: raise ValidationError({ @@ -1584,9 +1596,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): raise ValidationError({ 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address." }) - if self.primary_ip6.interface in vc_interfaces: + if self.primary_ip6.assigned_object in vc_interfaces: pass - elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces: + elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces: pass else: raise ValidationError({ @@ -1622,32 +1634,32 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleport_templates.unrestricted()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleserverport_templates.unrestricted()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerport_templates.all()] + [x.instantiate(self) for x in self.device_type.powerport_templates.unrestricted()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()] + [x.instantiate(self) for x in self.device_type.poweroutlet_templates.unrestricted()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interface_templates.all()] + [x.instantiate(self) for x in self.device_type.interface_templates.unrestricted()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearport_templates.all()] + [x.instantiate(self) for x in self.device_type.rearport_templates.unrestricted()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontport_templates.all()] + [x.instantiate(self) for x in self.device_type.frontport_templates.unrestricted()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.device_bay_templates.all()] + [x.instantiate(self) for x in self.device_type.device_bay_templates.unrestricted()] ) # Update Site and Rack assignment for any child Devices - devices = Device.objects.filter(parent_bay__device=self) + devices = Device.objects.unrestricted().filter(parent_bay__device=self) for device in devices: device.site = self.site device.rack = self.rack @@ -1738,7 +1750,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ Return the set of child Devices installed in DeviceBays within this Device. """ - return Device.objects.filter(parent_bay__device=self.pk) + return Device.objects.unrestricted().filter(parent_bay__device=self.pk) def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) @@ -1756,7 +1768,12 @@ class VirtualChassis(ChangeLoggedModel): master = models.OneToOneField( to='Device', on_delete=models.PROTECT, - related_name='vc_master_for' + related_name='vc_master_for', + blank=True, + null=True + ) + name = models.CharField( + max_length=64 ) domain = models.CharField( max_length=30, @@ -1766,14 +1783,14 @@ class VirtualChassis(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['master', 'domain'] + csv_headers = ['name', 'domain', 'master'] class Meta: - ordering = ['master'] + ordering = ['name'] verbose_name_plural = 'virtual chassis' def __str__(self): - return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis' + return self.name def get_absolute_url(self): return reverse('dcim:virtualchassis', kwargs={'pk': self.pk}) @@ -1782,15 +1799,15 @@ class VirtualChassis(ChangeLoggedModel): # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # VirtualChassis.) - if self.pk and self.master not in self.members.all(): + if self.pk and self.master and self.master not in self.members.all(): raise ValidationError({ - 'master': "The selected master is not assigned to this virtual chassis." + 'master': f"The selected master ({self.master}) is not assigned to this virtual chassis." }) def delete(self, *args, **kwargs): # Check for LAG interfaces split across member chassis - interfaces = Interface.objects.filter( + interfaces = Interface.objects.unrestricted().filter( device__in=self.members.all(), lag__isnull=False ).exclude( @@ -1798,8 +1815,7 @@ class VirtualChassis(ChangeLoggedModel): ) if interfaces: raise ProtectedError( - "Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis " - "LAG".format(self), + f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG", interfaces ) @@ -1807,8 +1823,9 @@ class VirtualChassis(ChangeLoggedModel): def to_csv(self): return ( - self.master, + self.name, self.domain, + self.master.name if self.master else None, ) @@ -2158,12 +2175,13 @@ class Cable(ChangeLoggedModel): return reverse('dcim:cable', args=[self.pk]) def clean(self): + from circuits.models import CircuitTermination # Validate that termination A exists if not hasattr(self, 'termination_a_type'): raise ValidationError('Termination A type has not been specified') try: - self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) + self.termination_a_type.model_class().objects.unrestricted().get(pk=self.termination_a_id) except ObjectDoesNotExist: raise ValidationError({ 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) @@ -2173,7 +2191,7 @@ class Cable(ChangeLoggedModel): if not hasattr(self, 'termination_b_type'): raise ValidationError('Termination B type has not been specified') try: - self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) + self.termination_b_type.model_class().objects.unrestricted().get(pk=self.termination_b_id) except ObjectDoesNotExist: raise ValidationError({ 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) @@ -2220,19 +2238,21 @@ class Cable(ChangeLoggedModel): f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" ) - # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions + # Check that a RearPort with multiple positions isn't connected to an endpoint + # or a RearPort with a different number of positions. for term_a, term_b in [ (self.termination_a, self.termination_b), (self.termination_b, self.termination_a) ]: if isinstance(term_a, RearPort) and term_a.positions > 1: - if not isinstance(term_b, RearPort): + if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)): raise ValidationError( - "Rear ports with multiple positions may only be connected to other rear ports" + "Rear ports with multiple positions may only be connected to other pass-through ports" ) - elif term_a.positions != term_b.positions: + if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions: raise ValidationError( - f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. " + f"{term_a} of {term_a.device} has {term_a.positions} position(s) but " + f"{term_b} of {term_b.device} has {term_b.positions}. " f"Both terminations must have the same number of positions." ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 904352196..1c2be0e5d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -27,6 +27,11 @@ __all__ = ( class ComponentTemplateModel(models.Model): + description = models.CharField( + max_length=200, + blank=True + ) + objects = RestrictedQuerySet.as_manager() class Meta: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a626c055f..aecf57544 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object -from virtualization.choices import VMInterfaceTypeChoices __all__ = ( @@ -53,18 +52,12 @@ class ComponentModel(models.Model): return self.name def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) - except ObjectDoesNotExist: - # The parent device/VM has already been deleted - parent = None - + # Annotate the parent Device return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent, + related_object=self.device, object_data=serialize_object(self) ) @@ -94,16 +87,16 @@ class CableTermination(models.Model): object_id_field='termination_b_id' ) - is_path_endpoint = True - class Meta: abstract = True def trace(self): """ - Return two items: the traceable portion of a cable path, and the termination points where it splits (if any). - This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where - the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow. + Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and + the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint + along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible + to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses + a FrontPort without traversing a RearPort again. The path is a list representing a complete cable path, with each individual segment represented as a three-tuple: @@ -123,26 +116,35 @@ class CableTermination(models.Model): # Map a front port to its corresponding rear port if isinstance(termination, FrontPort): - position_stack.append(termination.rear_port_position) # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance peer_port = RearPort.objects.get(pk=termination.rear_port.pk) + + # Don't use the stack for RearPorts with a single position. Only remember the position at + # many-to-one points so we can select the correct FrontPort when we reach the corresponding + # one-to-many point. + if peer_port.positions > 1: + position_stack.append(termination) + return peer_port # Map a rear port/position to its corresponding front port elif isinstance(termination, RearPort): + if termination.positions > 1: + # Can't map to a FrontPort without a position if there are multiple options + if not position_stack: + raise CableTraceSplit(termination) - # Can't map to a FrontPort without a position if there are multiple options - if termination.positions > 1 and not position_stack: - raise CableTraceSplit(termination) + front_port = position_stack.pop() + position = front_port.rear_port_position - # We can assume position 1 if the RearPort has only one position - position = position_stack.pop() if position_stack else 1 - - # Validate the position - if position not in range(1, termination.positions + 1): - raise Exception("Invalid position for {} ({} positions): {})".format( - termination, termination.positions, position - )) + # Validate the position + if position not in range(1, termination.positions + 1): + raise Exception("Invalid position for {} ({} positions): {})".format( + termination, termination.positions, position + )) + else: + # Don't use the stack for RearPorts with a single position. The only possible position is 1. + position = 1 try: peer_port = FrontPort.objects.get( @@ -173,12 +175,12 @@ class CableTermination(models.Model): if not endpoint.cable: path.append((endpoint, None, None)) logger.debug("No cable connected") - return path, None + return path, None, position_stack # Check for loops if endpoint.cable in [segment[1] for segment in path]: logger.debug("Loop detected!") - return path, None + return path, None, position_stack # Record the current segment in the path far_end = endpoint.get_cable_peer() @@ -191,10 +193,10 @@ class CableTermination(models.Model): try: endpoint = get_peer_port(far_end) except CableTraceSplit as e: - return path, e.termination.frontports.all() + return path, e.termination.frontports.all(), position_stack if endpoint is None: - return path, None + return path, None, position_stack def get_cable_peer(self): if self.cable is None: @@ -211,7 +213,7 @@ class CableTermination(models.Model): endpoints = [] # Get the far end of the last path segment - path, split_ends = self.trace() + path, split_ends, position_stack = self.trace() endpoint = path[-1][2] if split_ends is not None: for termination in split_ends: @@ -275,7 +277,7 @@ class ConsolePort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:consoleport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -332,7 +334,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -415,7 +417,7 @@ class PowerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:powerport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -567,7 +569,7 @@ class PowerOutlet(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -592,26 +594,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # -@extras_features('graphs', 'export_templates', 'webhooks') -class Interface(CableTermination, ComponentModel): - """ - A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other - Interface. - """ - device = models.ForeignKey( - to='Device', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) +class BaseInterface(models.Model): name = models.CharField( max_length=64 ) @@ -621,6 +604,42 @@ class Interface(CableTermination, ComponentModel): max_length=100, blank=True ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1), MaxValueValidator(65536)], + verbose_name='MTU' + ) + mode = models.CharField( + max_length=50, + choices=InterfaceModeChoices, + blank=True + ) + + class Meta: + abstract = True + + +@extras_features('graphs', 'export_templates', 'webhooks') +class Interface(CableTermination, ComponentModel, BaseInterface): + """ + A network interface within a Device. A physical Interface can connect to exactly one other Interface. + """ + device = models.ForeignKey( + to='Device', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) label = models.CharField( max_length=64, blank=True, @@ -656,30 +675,11 @@ class Interface(CableTermination, ComponentModel): max_length=50, choices=InterfaceTypeChoices ) - enabled = models.BooleanField( - default=True - ) - mac_address = MACAddressField( - null=True, - blank=True, - verbose_name='MAC Address' - ) - mtu = models.PositiveIntegerField( - blank=True, - null=True, - validators=[MinValueValidator(1), MaxValueValidator(65536)], - verbose_name='MTU' - ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', help_text='This interface is used only for out-of-band management' ) - mode = models.CharField( - max_length=50, - choices=InterfaceModeChoices, - blank=True - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -694,15 +694,19 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='interface' + ) tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', - 'description', 'mode', + 'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', ] class Meta: - # TODO: ordering and unique_together should include virtual_machine ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') @@ -712,7 +716,6 @@ class Interface(CableTermination, ComponentModel): def to_csv(self): return ( self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, self.name, self.lag.name if self.lag else None, self.get_type_display(), @@ -726,18 +729,6 @@ class Interface(CableTermination, ComponentModel): def clean(self): - # An Interface must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("An interface cannot belong to both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("An interface must belong to either a device or a virtual machine.") - - # VM interfaces must be virtual - if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values(): - raise ValidationError({ - 'type': "Invalid interface type for a virtual machine: {}".format(self.type) - }) - # Virtual interfaces cannot be connected if self.type in NONCONNECTABLE_IFACE_TYPES and ( self.cable or getattr(self, 'circuit_termination', False) @@ -773,7 +764,7 @@ class Interface(CableTermination, ComponentModel): if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: raise ValidationError({ 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " - "device/VM, or it must be global".format(self.untagged_vlan) + "device, or it must be global".format(self.untagged_vlan) }) def save(self, *args, **kwargs): @@ -788,21 +779,6 @@ class Interface(CableTermination, ComponentModel): return super().save(*args, **kwargs) - def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent_obj = self.device or self.virtual_machine - except ObjectDoesNotExist: - parent_obj = None - - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=parent_obj, - object_data=serialize_object(self) - ) - @property def connected_endpoint(self): """ @@ -841,7 +817,7 @@ class Interface(CableTermination, ComponentModel): @property def parent(self): - return self.device or self.virtual_machine + return self.device @property def is_connectable(self): @@ -902,7 +878,6 @@ class FrontPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - is_path_endpoint = False class Meta: ordering = ('device', '_name') @@ -914,6 +889,9 @@ class FrontPort(CableTermination, ComponentModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:frontport', kwargs={'pk': self.pk}) + def to_csv(self): return ( self.device.identifier, @@ -970,7 +948,6 @@ class RearPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] - is_path_endpoint = False class Meta: ordering = ('device', '_name') @@ -979,6 +956,9 @@ class RearPort(CableTermination, ComponentModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:rearport', kwargs={'pk': self.pk}) + def to_csv(self): return ( self.device.identifier, @@ -1038,7 +1018,7 @@ class DeviceBay(ComponentModel): return '{} - {}'.format(self.device.name, self.name) def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:devicebay', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -1147,7 +1127,7 @@ class InventoryItem(ComponentModel): return self.name def get_absolute_url(self): - return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk}) + return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) def to_csv(self): return ( diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index c94ecf61e..172c366b5 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,20 +4,19 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, Device, VirtualChassis +from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis @receiver(post_save, sender=VirtualChassis) def assign_virtualchassis_master(instance, created, **kwargs): """ - When a VirtualChassis is created, automatically assign its master device to the VC. + When a VirtualChassis is created, automatically assign its master device (if any) to the VC. """ - if created: - devices = Device.objects.filter(pk=instance.master.pk) - for device in devices: - device.virtual_chassis = instance - device.vc_position = None - device.save() + if created and instance.master: + master = Device.objects.get(pk=instance.master.pk) + master.virtual_chassis = instance + master.vc_position = 1 + master.save() @receiver(pre_delete, sender=VirtualChassis) @@ -52,7 +51,7 @@ def update_connected_endpoints(instance, **kwargs): # Update any endpoints for this Cable. endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() for endpoint in endpoints: - path, split_ends = endpoint.trace() + path, split_ends, position_stack = endpoint.trace() # Determine overall path status (connected or planned) path_status = True for segment in path: @@ -61,9 +60,11 @@ def update_connected_endpoints(instance, **kwargs): break endpoint_a = path[0][0] - endpoint_b = path[-1][2] + endpoint_b = path[-1][2] if not split_ends and not position_stack else None - if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): + # Patch panel ports are not connected endpoints, all other cable terminations are + if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \ + isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)): logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1589a7f6d..f258df221 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -2,7 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn, +) from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -40,69 +42,16 @@ DEVICE_LINK = """ """ -REGION_ACTIONS = """ - - - -{% if perms.dcim.change_region %} - -{% endif %} -""" - -RACKGROUP_ACTIONS = """ - - - +RACKGROUP_ELEVATIONS = """ -{% if perms.dcim.change_rackgroup %} - - - -{% endif %} -""" - -RACKROLE_ACTIONS = """ - - - -{% if perms.dcim.change_rackrole %} - -{% endif %} """ RACK_DEVICE_COUNT = """ {{ value }} """ -RACKRESERVATION_ACTIONS = """ - - - -{% if perms.dcim.change_rackreservation %} - -{% endif %} -""" - -MANUFACTURER_ACTIONS = """ - - - -{% if perms.dcim.change_manufacturer %} - -{% endif %} -""" - -DEVICEROLE_ACTIONS = """ - - - -{% if perms.dcim.change_devicerole %} - -{% endif %} -""" - DEVICEROLE_DEVICE_COUNT = """ {{ value }} """ @@ -119,15 +68,6 @@ PLATFORM_VM_COUNT = """ {{ value }} """ -PLATFORM_ACTIONS = """ - - - -{% if perms.dcim.change_platform %} - -{% endif %} -""" - STATUS_LABEL = """ {{ record.get_status_display }} """ @@ -198,11 +138,7 @@ class RegionTable(BaseTable): site_count = tables.Column( verbose_name='Sites' ) - actions = tables.TemplateColumn( - template_code=REGION_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): model = Region @@ -260,10 +196,9 @@ class RackGroupTable(BaseTable): rack_count = tables.Column( verbose_name='Racks' ) - actions = tables.TemplateColumn( - template_code=RACKGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' + actions = ButtonsColumn( + model=RackGroup, + prepend_template=RACKGROUP_ELEVATIONS ) class Meta(BaseTable.Meta): @@ -280,11 +215,7 @@ class RackRoleTable(BaseTable): pk = ToggleColumn() rack_count = tables.Column(verbose_name='Racks') color = tables.TemplateColumn(COLOR_LABEL) - actions = tables.TemplateColumn( - template_code=RACKROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): model = RackRole @@ -386,11 +317,7 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = tables.TemplateColumn( - template_code=RACKRESERVATION_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RackReservation) class Meta(BaseTable.Meta): model = RackReservation @@ -420,11 +347,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - actions = tables.TemplateColumn( - template_code=MANUFACTURER_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Manufacturer, pk_field='slug') class Meta(BaseTable.Meta): model = Manufacturer @@ -486,22 +409,10 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = ConsolePortTemplate - fields = ('pk', 'name', 'label', 'type', 'actions') + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" -class ConsolePortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = ConsolePort - fields = ('device', 'name', 'description') - empty_text = False - - class ConsoleServerPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleserverporttemplate'), @@ -511,22 +422,10 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = ConsoleServerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'actions') + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" -class ConsoleServerPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = ConsoleServerPort - fields = ('device', 'name', 'description') - empty_text = False - - class PowerPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('powerporttemplate'), @@ -536,22 +435,10 @@ class PowerPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = PowerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'actions') + fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') empty_text = "None" -class PowerPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = PowerPort - fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw') - empty_text = False - - class PowerOutletTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('poweroutlettemplate'), @@ -561,22 +448,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = PowerOutletTemplate - fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'actions') + fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') empty_text = "None" -class PowerOutletImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = PowerOutlet - fields = ('device', 'name', 'description', 'power_port', 'feed_leg') - empty_text = False - - class InterfaceTemplateTable(ComponentTemplateTable): mgmt_only = BooleanColumn( verbose_name='Management Only' @@ -589,30 +464,10 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'actions') + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') empty_text = "None" -class InterfaceImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - virtual_machine = tables.LinkColumn( - viewname='virtualization:virtualmachine', - args=[Accessor('virtual_machine.pk')], - verbose_name='Virtual Machine' - ) - - class Meta(BaseTable.Meta): - model = Interface - fields = ( - 'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', - 'mgmt_only', 'mode', - ) - empty_text = False - - class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' @@ -625,22 +480,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = FrontPortTemplate - fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'actions') + fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions') empty_text = "None" -class FrontPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = FrontPort - fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position') - empty_text = False - - class RearPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('rearporttemplate'), @@ -650,22 +493,10 @@ class RearPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = RearPortTemplate - fields = ('pk', 'name', 'label', 'type', 'positions', 'actions') + fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions') empty_text = "None" -class RearPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = RearPort - fields = ('device', 'name', 'description', 'type', 'position') - empty_text = False - - class DeviceBayTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('devicebaytemplate'), @@ -675,7 +506,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = DeviceBayTemplate - fields = ('pk', 'name', 'label', 'actions') + fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" @@ -701,11 +532,8 @@ class DeviceRoleTable(BaseTable): template_code=COLOR_LABEL, verbose_name='Label' ) - actions = tables.TemplateColumn( - template_code=DEVICEROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + vm_role = BooleanColumn() + actions = ButtonsColumn(DeviceRole, pk_field='slug') class Meta(BaseTable.Meta): model = DeviceRole @@ -731,11 +559,7 @@ class PlatformTable(BaseTable): orderable=False, verbose_name='VMs' ) - actions = tables.TemplateColumn( - template_code=PLATFORM_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Platform, pk_field='slug') class Meta(BaseTable.Meta): model = Platform @@ -861,153 +685,108 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentDetailTable(BaseTable): +class DeviceComponentTable(BaseTable): pk = ToggleColumn() - name = tables.Column(order_by=('_name',)) - cable = tables.LinkColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + order_by=('_name',) + ) + cable = tables.Column( + linkify=True + ) class Meta(BaseTable.Meta): order_by = ('device', 'name') - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') - sequence = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') -class ConsolePortTable(BaseTable): - name = tables.Column(order_by=('_name',)) +class ConsolePortTable(DeviceComponentTable): - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('name', 'label', 'type') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsolePortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() +class ConsoleServerPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): - pass - - -class ConsoleServerPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('name', 'label', 'description') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsoleServerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() +class PowerPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): - pass - - -class PowerPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = PowerPort - fields = ('name', 'label', 'type') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() +class PowerOutletTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): - pass - - -class PowerOutletTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = PowerOutlet - fields = ('name', 'label', 'type', 'description') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') -class PowerOutletDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() - - class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): - pass - - -class InterfaceTable(BaseTable): - - class Meta(BaseTable.Meta): - model = Interface - fields = ('name', 'label', 'type', 'lag', 'enabled', 'mgmt_only', 'description') - - -class InterfaceDetailTable(DeviceComponentDetailTable): - parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) - name = tables.LinkColumn() +class InterfaceTable(DeviceComponentTable): enabled = BooleanColumn() - class Meta(InterfaceTable.Meta): - order_by = ('parent', 'name') - fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') - sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') + class Meta(DeviceComponentTable.Meta): + model = Interface + fields = ( + 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'description', 'cable', + ) + default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') -class FrontPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) +class FrontPortTable(DeviceComponentTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = FrontPort - fields = ('name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') - empty_text = "None" + fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') -class FrontPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() +class RearPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): - pass - - -class RearPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('name', 'label', 'type', 'positions', 'description') - empty_text = "None" + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class RearPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() +class DeviceBayTable(DeviceComponentTable): + installed_device = tables.Column( + linkify=True + ) - class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): - pass - - -class DeviceBayTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('name', 'label', 'description') - - -class DeviceBayDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() - installed_device = tables.LinkColumn() - - class Meta(DeviceBayTable.Meta): fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description') - sequence = ('pk', 'device', 'name', 'label', 'installed_device', 'description') - exclude = ('cable',) + default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') -class DeviceBayImportTable(BaseTable): - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - installed_device = tables.LinkColumn('dcim:device', args=[Accessor('installed_device.pk')], verbose_name='Installed Device') +class InventoryItemTable(DeviceComponentTable): + manufacturer = tables.Column( + linkify=True + ) + discovered = BooleanColumn() - class Meta(BaseTable.Meta): - model = DeviceBay - fields = ('device', 'name', 'installed_device', 'description') - empty_text = False + class Meta(DeviceComponentTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered' + ) + default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag') # @@ -1152,29 +931,6 @@ class InterfaceConnectionTable(BaseTable): ) -# -# InventoryItems -# - -class InventoryItemTable(BaseTable): - pk = ToggleColumn() - device = tables.LinkColumn( - viewname='dcim:device_inventory', - args=[Accessor('device.pk')] - ) - manufacturer = tables.Column( - accessor=Accessor('manufacturer') - ) - discovered = BooleanColumn() - - class Meta(BaseTable.Meta): - model = InventoryItem - fields = ( - 'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered' - ) - default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag') - - # # Virtual chassis # @@ -1182,7 +938,9 @@ class InventoryItemTable(BaseTable): class VirtualChassisTable(BaseTable): pk = ToggleColumn() name = tables.Column( - accessor=Accessor('master__name'), + linkify=True + ) + master = tables.Column( linkify=True ) member_count = tables.Column( @@ -1194,8 +952,8 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis - fields = ('pk', 'name', 'domain', 'member_count', 'tags') - default_columns = ('pk', 'name', 'domain', 'member_count') + fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags') + default_columns = ('pk', 'name', 'domain', 'master', 'member_count') # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index cb0eed994..451d9d9a9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -28,6 +28,43 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class Mixins: + + class ComponentTraceMixin(APITestCase): + peer_termination_type = None + + def test_trace(self): + """ + Test tracing a device component's attached cable. + """ + obj = self.model.objects.unrestricted().first() + peer_device = Device.objects.create( + site=Site.objects.unrestricted().first(), + device_type=DeviceType.objects.unrestricted().first(), + device_role=DeviceRole.objects.unrestricted().first(), + name='Peer Device' + ) + if self.peer_termination_type is None: + raise NotImplementedError("Test case must set peer_termination_type") + peer_obj = self.peer_termination_type.objects.create( + device=peer_device, + name='Peer Termination' + ) + cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') + cable.save() + + self.add_permissions(f'dcim.view_{self.model._meta.model_name}') + url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], obj.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], peer_obj.name) + + class RegionTest(APIViewTestCases.APIViewTestCase): model = Region brief_fields = ['id', 'name', 'site_count', 'slug', 'url'] @@ -107,7 +144,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_site') - url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk}) + url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.unrestricted().unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) @@ -246,7 +283,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): """ GET a single rack elevation. """ - rack = Rack.objects.first() + rack = Rack.objects.unrestricted().first() self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}) @@ -266,7 +303,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): """ GET a single rack elevation in SVG format. """ - rack = Rack.objects.first() + rack = Rack.objects.unrestricted().first() self.add_permissions('dcim.view_rack') url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) @@ -281,9 +318,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - user = User.objects.create(username='user1', is_active=True) - site = Site.objects.create(name='Test Site 1', slug='test-site-1') cls.racks = ( @@ -878,7 +913,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_device') - url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk}) + url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) @@ -908,7 +943,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): """ Check that creating a device with a duplicate name within a site fails. """ - device = Device.objects.first() + device = Device.objects.unrestricted().first() data = { 'device_type': device.device_type.pk, 'device_role': device.device_role.pk, @@ -923,9 +958,10 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class ConsolePortTest(APIViewTestCases.APIViewTestCase): +class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = ConsoleServerPort @classmethod def setUpTestData(cls): @@ -957,39 +993,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_consoleport(self): - """ - Test tracing a ConsolePort cable. - """ - consoleport = ConsolePort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - consoleserverport = ConsoleServerPort.objects.create( - device=peer_device, - name='Console Server Port 1' - ) - cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_consoleport') - url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], consoleport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], consoleserverport.name) - - -class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = ConsolePort @classmethod def setUpTestData(cls): @@ -1021,39 +1029,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_consoleserverport(self): - """ - Test tracing a ConsoleServerPort cable. - """ - consoleserverport = ConsoleServerPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - consoleport = ConsolePort.objects.create( - device=peer_device, - name='Console Port 1' - ) - cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_consoleserverport') - url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], consoleserverport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], consoleport.name) - - -class PowerPortTest(APIViewTestCases.APIViewTestCase): +class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = PowerOutlet @classmethod def setUpTestData(cls): @@ -1085,39 +1065,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_powerport(self): - """ - Test tracing a PowerPort cable. - """ - powerport = PowerPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - poweroutlet = PowerOutlet.objects.create( - device=peer_device, - name='Power Outlet 1' - ) - cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_powerport') - url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], powerport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], poweroutlet.name) - - -class PowerOutletTest(APIViewTestCases.APIViewTestCase): +class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = PowerPort @classmethod def setUpTestData(cls): @@ -1149,39 +1101,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_poweroutlet(self): - """ - Test tracing a PowerOutlet cable. - """ - poweroutlet = PowerOutlet.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - powerport = PowerPort.objects.create( - device=peer_device, - name='Power Port 1' - ) - cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_poweroutlet') - url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], poweroutlet.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], powerport.name) - - -class InterfaceTest(APIViewTestCases.APIViewTestCase): +class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1245,45 +1169,17 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_interface') - url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk}) + url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') - def test_trace_interface(self): - """ - Test tracing an Interface cable. - """ - interface_a = Interface.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface_b = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_interface') - url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], interface_a.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface_b.name) - - -class FrontPortTest(APIViewTestCases.APIViewTestCase): +class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1334,39 +1230,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_frontport(self): - """ - Test tracing a FrontPort cable. - """ - frontport = FrontPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_frontport') - url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], frontport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface.name) - - -class RearPortTest(APIViewTestCases.APIViewTestCase): +class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1401,35 +1269,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_rearport(self): - """ - Test tracing a RearPort cable. - """ - rearport = RearPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1') - cable.save() - - self.add_permissions('dcim.view_rearport') - url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], rearport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface.name) - class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay @@ -1640,11 +1479,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk) self.assertEqual(cable.termination_a, consoleport1) self.assertEqual(cable.termination_b, consoleserverport1) @@ -1705,12 +1544,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk) self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) @@ -1735,11 +1574,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - powerport1 = PowerPort.objects.get(pk=powerport1.pk) - poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + powerport1 = PowerPort.objects.unrestricted().get(pk=powerport1.pk) + poweroutlet1 = PowerOutlet.objects.unrestricted().get(pk=poweroutlet1.pk) self.assertEqual(cable.termination_a, powerport1) self.assertEqual(cable.termination_b, poweroutlet1) @@ -1771,11 +1610,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + interface2 = Interface.objects.unrestricted().get(pk=interface2.pk) self.assertEqual(cable.termination_a, interface1) self.assertEqual(cable.termination_b, interface2) @@ -1836,12 +1675,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + interface2 = Interface.objects.unrestricted().get(pk=interface2.pk) self.assertEqual(interface1.connected_endpoint, interface2) self.assertEqual(interface2.connected_endpoint, interface1) @@ -1875,11 +1714,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk) self.assertEqual(cable.termination_a, interface1) self.assertEqual(cable.termination_b, circuittermination1) @@ -1949,12 +1788,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk) self.assertEqual(interface1.connected_endpoint, circuittermination1) self.assertEqual(circuittermination1.connected_endpoint, interface1) @@ -2003,7 +1842,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['id', 'master', 'member_count', 'url'] + brief_fields = ['id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -2040,34 +1879,35 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): # Create three VirtualChassis with three members each virtual_chassis = ( - VirtualChassis(master=devices[0], domain='domain-1'), - VirtualChassis(master=devices[3], domain='domain-2'), - VirtualChassis(master=devices[6], domain='domain-3'), + VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'), + VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'), + VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'), ) VirtualChassis.objects.bulk_create(virtual_chassis) - Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) - Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) - Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) - Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) - Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) - Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) cls.update_data = { - 'master': devices[1].pk, + 'name': 'Virtual Chassis X', 'domain': 'domain-x', + 'master': devices[1].pk, } cls.create_data = [ { - 'master': devices[9].pk, + 'name': 'Virtual Chassis 4', 'domain': 'domain-4', }, { - 'master': devices[10].pk, + 'name': 'Virtual Chassis 5', 'domain': 'domain-5', }, { - 'master': devices[11].pk, + 'name': 'Virtual Chassis 6', 'domain': 'domain-6', }, ] diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 6c261f025..d4504d586 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase): # Assign primary IPs for filtering ipaddresses = ( - IPAddress(address='192.0.2.1/24', interface=interfaces[0]), - IPAddress(address='192.0.2.2/24', interface=interfaces[1]), + IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), + IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), ) IPAddress.objects.bulk_create(ipaddresses) Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0]) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 6db938732..c55d099c9 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -363,6 +363,7 @@ class CableTestCase(TestCase): ) self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.interface3 = Interface.objects.create(device=self.device2, name='eth1') self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) self.cable.save() @@ -370,10 +371,27 @@ class CableTestCase(TestCase): self.patch_pannel = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site ) - self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000) - self.front_port = FrontPort.objects.create( - device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port + self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c') + self.front_port1 = FrontPort.objects.create( + device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1 ) + self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2) + self.front_port2 = FrontPort.objects.create( + device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1 + ) + self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3) + self.front_port3 = FrontPort.objects.create( + device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1 + ) + self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3) + self.front_port4 = FrontPort.objects.create( + device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 + ) + self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000) + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000) def test_cable_creation(self): """ @@ -405,7 +423,7 @@ class CableTestCase(TestCase): cable = Cable.objects.filter(pk=self.cable.pk).first() self.assertIsNone(cable) - def test_cable_validates_compatibale_types(self): + def test_cable_validates_compatible_types(self): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ @@ -426,7 +444,7 @@ class CableTestCase(TestCase): """ A cable cannot connect a front port to its corresponding rear port """ - cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) + cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1) with self.assertRaises(ValidationError): cable.clean() @@ -439,7 +457,94 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() - def test_cable_cannot_terminate_to_a_virtual_inteface(self): + def test_connection_via_single_position_rearport(self): + """ + A RearPort with one position can be connected to anything. + + [CableTermination X]---[RP(pos=1) FP]---[CableTermination Y] + + is allowed anywhere + + [CableTermination X]---[CableTermination Y] + + is allowed. + + A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort + with a different number of positions. RearPorts with a single position on the other hand may be connected + to such CableTerminations. Check that this is indeed allowed. + """ + # Connecting a single-position RearPort to a multi-position RearPort is ok + Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() + + # Connecting a single-position RearPort to an Interface is ok + Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean() + + # Connecting a single-position RearPort to a CircuitTermination is ok + Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() + + def test_connection_via_multi_position_rearport(self): + """ + A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort + with a different number of positions. + + The following scenario's are allowed (with x>1): + + ~----------+ +---------~ + | | + RP2(pos=x)|---|RP(pos=x) + | | + ~----------+ +---------~ + + ~----------+ +---------~ + | | + RP2(pos=x)|---|RP(pos=1) + | | + ~----------+ +---------~ + + ~----------+ +------------------~ + | | + RP2(pos=x)|---|CircuitTermination + | | + ~----------+ +------------------~ + + These scenarios are NOT allowed (with x>1): + + ~----------+ +----------~ + | | + RP2(pos=x)|---|RP(pos!=x) + | | + ~----------+ +----------~ + + ~----------+ +----------~ + | | + RP2(pos=x)|---|Interface + | | + ~----------+ +----------~ + + These scenarios are tested in this order below. + """ + # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok + Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean() + + # Connecting a multi-position RearPort to a single-position RearPort is ok + Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean() + + # Connecting a multi-position RearPort to a CircuitTermination is ok + Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean() + + with self.assertRaises( + ValidationError, + msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' + ): + Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean() + + with self.assertRaises( + ValidationError, + msg='Connecting a multi-position RearPort to an Interface should fail' + ): + Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean() + + def test_cable_cannot_terminate_to_a_virtual_interface(self): """ A cable cannot terminate to a virtual interface """ @@ -448,7 +553,7 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() - def test_cable_cannot_terminate_to_a_wireless_inteface(self): + def test_cable_cannot_terminate_to_a_wireless_interface(self): """ A cable cannot terminate to a wireless interface """ @@ -501,9 +606,13 @@ class CablePathTestCase(TestCase): Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site), + Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site), + Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site), ) Device.objects.bulk_create(patch_panels) - for patch_panel in patch_panels: + + # Create patch panels with 4 positions + for patch_panel in patch_panels[:4]: rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C) FrontPort.objects.bulk_create(( FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C), @@ -512,6 +621,11 @@ class CablePathTestCase(TestCase): FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C), )) + # Create 1-on-1 patch panels + for patch_panel in patch_panels[4:]: + rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) + FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) + def test_direct_connection(self): """ Test a direct connection between two interfaces. @@ -524,6 +638,7 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable.full_clean() cable.save() # Retrieve endpoints @@ -551,22 +666,25 @@ class CablePathTestCase(TestCase): def test_connection_via_single_rear_port(self): """ - Test a connection which passes through a single front/rear port pair. + Test a connection which passes through a rear port with exactly one front port. 1 2 - [Device 1] ----- [Panel 1] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Device 2] Iface1 FP1 RP1 Iface1 """ - # Create cables + # Create cables (FP first, RP second) cable1 = Cable( termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), + termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + self.assertEqual(cable2.termination_a.positions, 1) # Sanity check + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -592,6 +710,97 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) + def test_connections_via_nested_single_position_rearport(self): + """ + Test a connection which passes through a single front/rear port pair between two multi-position rear ports. + + Test two connections via patched rear ports: + Device 1 <---> Device 2 + Device 3 <---> Device 4 + + 1 2 + [Device 1] -----------+ +----------- [Device 2] + Iface1 | | Iface1 + FP1 | 3 4 | FP1 + [Panel 1] ----- [Panel 5] ----- [Panel 2] + FP2 | RP1 RP1 FP1 RP1 | FP2 + Iface1 | | Iface1 + [Device 3] -----------+ +----------- [Device 4] + 5 6 + """ + # Create cables (Panel 5 RP first, FP second) + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + ) + cable1.full_clean() + cable1.save() + cable2 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), + termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + ) + cable2.full_clean() + cable2.save() + cable3 = Cable( + termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') + ) + cable3.full_clean() + cable3.save() + cable4 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') + ) + cable4.full_clean() + cable4.save() + cable5 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'), + termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1') + ) + cable5.full_clean() + cable5.save() + cable6 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), + termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1') + ) + cable6.full_clean() + cable6.save() + + # Retrieve endpoints + endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') + endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') + endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) + + # Delete cable 3 + cable3.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) + def test_connections_via_patch(self): """ Test two connections via patched rear ports: @@ -613,28 +822,33 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2') ) + cable5.full_clean() cable5.save() # Retrieve endpoints @@ -693,43 +907,51 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') ) + cable5.full_clean() cable5.save() cable6 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable6.full_clean() cable6.save() cable7 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2') ) + cable7.full_clean() cable7.save() cable8 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') ) + cable8.full_clean() cable8.save() # Retrieve endpoints @@ -789,38 +1011,45 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') ) + cable5.full_clean() cable5.save() cable6 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable6.full_clean() cable6.save() cable7 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') ) + cable7.full_clean() cable7.save() # Retrieve endpoints @@ -870,11 +1099,13 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -903,30 +1134,34 @@ class CablePathTestCase(TestCase): def test_connection_via_patched_circuit(self): """ 1 2 3 4 - [Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2] Iface1 FP1 RP1 A Z RP1 FP1 Iface1 """ # Create cables cable1 = Cable( termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') + termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), + termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable4.full_clean() cable4.save() # Retrieve endpoints diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b9a02b318..799d186b2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -813,14 +813,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } -# TODO: Change base class to DeviceComponentTemplateViewTestCase -# Blocked by absence of bulk edit view for DeviceBays -class DeviceBayTemplateTestCase( - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.BulkCreateObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate @classmethod @@ -848,6 +841,10 @@ class DeviceBayTemplateTestCase( 'name_pattern': 'Device Bay Template [4-6]', } + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole @@ -1194,10 +1191,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -class InterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.DeviceComponentViewTestCase, -): +class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface @classmethod @@ -1563,16 +1557,7 @@ class CableTestCase( } -# TODO: Change base class to PrimaryObjectViewTestCase -# Blocked by standard creation, bulk creation views for VirtualChassis (member devices must be selected in bulk) -class VirtualChassisTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis @classmethod @@ -1587,7 +1572,6 @@ class VirtualChassisTestCase( name='Device Role', slug='device-role-1' ) - # Create 9 member Devices devices = ( Device(device_type=device_type, device_role=device_role, name='Device 1', site=site), Device(device_type=device_type, device_role=device_role, name='Device 2', site=site), @@ -1598,23 +1582,29 @@ class VirtualChassisTestCase( Device(device_type=device_type, device_role=device_role, name='Device 7', site=site), Device(device_type=device_type, device_role=device_role, name='Device 8', site=site), Device(device_type=device_type, device_role=device_role, name='Device 9', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 10', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 11', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 12', site=site), ) Device.objects.bulk_create(devices) - # Create three VirtualChassis with two members each - vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1') + # Create three VirtualChassis with three members each + vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1') + Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2) Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3) - vc2 = VirtualChassis.objects.create(master=devices[3], domain='domain-2') + vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2') + Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1) Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3) - vc3 = VirtualChassis.objects.create(master=devices[6], domain='domain-3') + vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3') + Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1) Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2) Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3) cls.form_data = { - 'master': devices[1].pk, - 'domain': 'domain-x', + 'name': 'VC4', + 'domain': 'domain-4', # Management form data for VC members 'form-TOTAL_FORMS': 0, 'form-INITIAL_FORMS': 3, @@ -1622,6 +1612,13 @@ class VirtualChassisTestCase( 'form-MAX_NUM_FORMS': 1000, } + cls.csv_data = ( + "name,domain,master", + "VC4,Domain 4,Device 10", + "VC5,Domain 5,Device 11", + "VC6,Domain 6,Device 12", + ) + cls.bulk_edit_data = { 'domain': 'domain-x', } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a0d6bdc92..7af91f0ae 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -4,9 +4,9 @@ from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceEditView from . import views from .models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, - PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, - VirtualChassis, + Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, + RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -18,6 +18,7 @@ urlpatterns = [ path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites @@ -38,6 +39,7 @@ urlpatterns = [ path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path('rack-groups//delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'), path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles @@ -46,6 +48,7 @@ urlpatterns = [ path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations @@ -78,6 +81,7 @@ urlpatterns = [ path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types @@ -142,7 +146,7 @@ urlpatterns = [ # Device bay templates path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), - # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), + path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), @@ -153,6 +157,7 @@ urlpatterns = [ path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms @@ -161,6 +166,7 @@ urlpatterns = [ path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices @@ -187,12 +193,15 @@ urlpatterns = [ path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), - # TODO: Bulk rename, disconnect views for ConsolePorts + path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'), + path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'), path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -203,10 +212,12 @@ urlpatterns = [ path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'), path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -214,12 +225,15 @@ urlpatterns = [ path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), - # TODO: Bulk rename, disconnect views for PowerPorts + path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'), + path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'), path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -230,10 +244,12 @@ urlpatterns = [ path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'), path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -244,12 +260,12 @@ urlpatterns = [ path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -260,10 +276,12 @@ urlpatterns = [ path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path('front-ports//', views.FrontPortView.as_view(), name='frontport'), path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -274,10 +292,12 @@ urlpatterns = [ path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path('rear-ports//', views.RearPortView.as_view(), name='rearport'), path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Device bays @@ -287,8 +307,10 @@ urlpatterns = [ path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'), path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'), path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}), path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), @@ -298,10 +320,13 @@ urlpatterns = [ path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'), path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - # TODO: Bulk rename view for InventoryItems + path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'), path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), + path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'), # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), @@ -321,6 +346,7 @@ urlpatterns = [ # Virtual chassis path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'), path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'), path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'), path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6aad18bd3..0c373cb24 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,13 +1,12 @@ from collections import OrderedDict -import re from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction -from django.db.models import Count, F -from django.forms import modelformset_factory +from django.db.models import Count, F, Prefetch +from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape @@ -17,7 +16,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.models import Graph from extras.views import ObjectConfigContextView -from ipam.models import Prefix, Service, VLAN +from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from secrets.models import Secret from utilities.forms import ConfirmationForm @@ -25,8 +24,9 @@ from utilities.paginator import EnhancedPaginator from utilities.permissions import get_permission_for_model from utilities.utils import csv_format from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, + GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -41,66 +41,25 @@ from .models import ( ) -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - An extendable view for renaming device components in bulk. - """ - queryset = None - form = None - template_name = 'dcim/bulk_rename.html' - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'change') - - def post(self, request): - - if '_preview' in request.POST or '_apply' in request.POST: - form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - if form.is_valid(): - for obj in selected_objects: - find = form.cleaned_data['find'] - replace = form.cleaned_data['replace'] - if form.cleaned_data['use_regex']: - try: - obj.new_name = re.sub(find, replace, obj.name) - # Catch regex group reference errors - except re.error: - obj.new_name = obj.name - else: - obj.new_name = obj.name.replace(find, replace) - - if '_apply' in request.POST: - for obj in selected_objects: - obj.name = obj.new_name - obj.save() - messages.success(request, "Renamed {} {}".format( - len(selected_objects), - self.queryset.model._meta.verbose_name_plural - )) - return redirect(self.get_return_url(request)) - - else: - form = self.form(initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - return render(request, self.template_name, { - 'form': form, - 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, - 'selected_objects': selected_objects, - 'return_url': self.get_return_url(request), - }) - - class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ queryset = None - form = None template_name = 'dcim/bulk_disconnect.html' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a new Form class from ConfirmationForm + class _Form(ConfirmationForm): + pk = ModelMultipleChoiceField( + queryset=self.queryset, + widget=MultipleHiddenInput() + ) + + self.form = _Form + def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') @@ -161,21 +120,22 @@ class RegionListView(ObjectListView): class RegionEditView(ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm - default_return_url = 'dcim:region_list' + + +class RegionDeleteView(ObjectDeleteView): + queryset = Region.objects.all() class RegionBulkImportView(BulkImportView): queryset = Region.objects.all() model_form = forms.RegionCSVForm table = tables.RegionTable - default_return_url = 'dcim:region_list' class RegionBulkDeleteView(BulkDeleteView): queryset = Region.objects.all() filterset = filters.RegionFilterSet table = tables.RegionTable - default_return_url = 'dcim:region_list' # @@ -220,19 +180,16 @@ class SiteEditView(ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm template_name = 'dcim/site_edit.html' - default_return_url = 'dcim:site_list' class SiteDeleteView(ObjectDeleteView): queryset = Site.objects.all() - default_return_url = 'dcim:site_list' class SiteBulkImportView(BulkImportView): queryset = Site.objects.all() model_form = forms.SiteCSVForm table = tables.SiteTable - default_return_url = 'dcim:site_list' class SiteBulkEditView(BulkEditView): @@ -240,14 +197,12 @@ class SiteBulkEditView(BulkEditView): filterset = filters.SiteFilterSet table = tables.SiteTable form = forms.SiteBulkEditForm - default_return_url = 'dcim:site_list' class SiteBulkDeleteView(BulkDeleteView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable - default_return_url = 'dcim:site_list' # @@ -270,21 +225,22 @@ class RackGroupListView(ObjectListView): class RackGroupEditView(ObjectEditView): queryset = RackGroup.objects.all() model_form = forms.RackGroupForm - default_return_url = 'dcim:rackgroup_list' + + +class RackGroupDeleteView(ObjectDeleteView): + queryset = RackGroup.objects.all() class RackGroupBulkImportView(BulkImportView): queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm table = tables.RackGroupTable - default_return_url = 'dcim:rackgroup_list' class RackGroupBulkDeleteView(BulkDeleteView): queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filterset = filters.RackGroupFilterSet table = tables.RackGroupTable - default_return_url = 'dcim:rackgroup_list' # @@ -299,20 +255,21 @@ class RackRoleListView(ObjectListView): class RackRoleEditView(ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm - default_return_url = 'dcim:rackrole_list' + + +class RackRoleDeleteView(ObjectDeleteView): + queryset = RackRole.objects.all() class RackRoleBulkImportView(BulkImportView): queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm table = tables.RackRoleTable - default_return_url = 'dcim:rackrole_list' class RackRoleBulkDeleteView(BulkDeleteView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable - default_return_url = 'dcim:rackrole_list' # @@ -404,19 +361,16 @@ class RackEditView(ObjectEditView): queryset = Rack.objects.all() model_form = forms.RackForm template_name = 'dcim/rack_edit.html' - default_return_url = 'dcim:rack_list' class RackDeleteView(ObjectDeleteView): queryset = Rack.objects.all() - default_return_url = 'dcim:rack_list' class RackBulkImportView(BulkImportView): queryset = Rack.objects.all() model_form = forms.RackCSVForm table = tables.RackTable - default_return_url = 'dcim:rack_list' class RackBulkEditView(BulkEditView): @@ -424,14 +378,12 @@ class RackBulkEditView(BulkEditView): filterset = filters.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm - default_return_url = 'dcim:rack_list' class RackBulkDeleteView(BulkDeleteView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable - default_return_url = 'dcim:rack_list' # @@ -462,7 +414,6 @@ class RackReservationEditView(ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm template_name = 'dcim/rackreservation_edit.html' - default_return_url = 'dcim:rackreservation_list' def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -474,14 +425,12 @@ class RackReservationEditView(ObjectEditView): class RackReservationDeleteView(ObjectDeleteView): queryset = RackReservation.objects.all() - default_return_url = 'dcim:rackreservation_list' class RackReservationImportView(BulkImportView): queryset = RackReservation.objects.all() model_form = forms.RackReservationCSVForm table = tables.RackReservationTable - default_return_url = 'dcim:rackreservation_list' def _save_obj(self, obj_form, request): """ @@ -499,14 +448,12 @@ class RackReservationBulkEditView(BulkEditView): filterset = filters.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm - default_return_url = 'dcim:rackreservation_list' class RackReservationBulkDeleteView(BulkDeleteView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable - default_return_url = 'dcim:rackreservation_list' # @@ -525,20 +472,21 @@ class ManufacturerListView(ObjectListView): class ManufacturerEditView(ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm - default_return_url = 'dcim:manufacturer_list' + + +class ManufacturerDeleteView(ObjectDeleteView): + queryset = Manufacturer.objects.all() class ManufacturerBulkImportView(BulkImportView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm table = tables.ManufacturerTable - default_return_url = 'dcim:manufacturer_list' class ManufacturerBulkDeleteView(BulkDeleteView): queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable - default_return_url = 'dcim:manufacturer_list' # @@ -558,6 +506,7 @@ class DeviceTypeView(ObjectView): def get(self, request, pk): devicetype = get_object_or_404(self.queryset, pk=pk) + instance_count = Device.objects.restrict(request.user).filter(device_type=devicetype).count() # Component tables consoleport_table = tables.ConsolePortTemplateTable( @@ -604,6 +553,7 @@ class DeviceTypeView(ObjectView): return render(request, 'dcim/devicetype.html', { 'devicetype': devicetype, + 'instance_count': instance_count, 'consoleport_table': consoleport_table, 'consoleserverport_table': consoleserverport_table, 'powerport_table': powerport_table, @@ -619,12 +569,10 @@ class DeviceTypeEditView(ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' - default_return_url = 'dcim:devicetype_list' class DeviceTypeDeleteView(ObjectDeleteView): queryset = DeviceType.objects.all() - default_return_url = 'dcim:devicetype_list' class DeviceTypeImportView(ObjectImportView): @@ -651,7 +599,6 @@ class DeviceTypeImportView(ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), )) - default_return_url = 'dcim:devicetype_import' class DeviceTypeBulkEditView(BulkEditView): @@ -659,14 +606,12 @@ class DeviceTypeBulkEditView(BulkEditView): filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm - default_return_url = 'dcim:devicetype_list' class DeviceTypeBulkDeleteView(BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable - default_return_url = 'dcim:devicetype_list' # @@ -906,10 +851,10 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() -# class DeviceBayTemplateBulkEditView(BulkEditView): -# queryset = DeviceBayTemplate.objects.all() -# table = tables.DeviceBayTemplateTable -# form = forms.DeviceBayTemplateBulkEditForm +class DeviceBayTemplateBulkEditView(BulkEditView): + queryset = DeviceBayTemplate.objects.all() + table = tables.DeviceBayTemplateTable + form = forms.DeviceBayTemplateBulkEditForm class DeviceBayTemplateBulkDeleteView(BulkDeleteView): @@ -929,20 +874,21 @@ class DeviceRoleListView(ObjectListView): class DeviceRoleEditView(ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm - default_return_url = 'dcim:devicerole_list' + + +class DeviceRoleDeleteView(ObjectDeleteView): + queryset = DeviceRole.objects.all() class DeviceRoleBulkImportView(BulkImportView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm table = tables.DeviceRoleTable - default_return_url = 'dcim:devicerole_list' class DeviceRoleBulkDeleteView(BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable - default_return_url = 'dcim:devicerole_list' # @@ -957,20 +903,21 @@ class PlatformListView(ObjectListView): class PlatformEditView(ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm - default_return_url = 'dcim:platform_list' + + +class PlatformDeleteView(ObjectDeleteView): + queryset = Platform.objects.all() class PlatformBulkImportView(BulkImportView): queryset = Platform.objects.all() model_form = forms.PlatformCSVForm table = tables.PlatformTable - default_return_url = 'dcim:platform_list' class PlatformBulkDeleteView(BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable - default_return_url = 'dcim:platform_list' # @@ -1028,8 +975,10 @@ class DeviceView(ObjectView): # Interfaces interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( + Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), + Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', - 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' + 'cable__termination_a', 'cable__termination_b', 'tags' ) # Front ports @@ -1155,12 +1104,10 @@ class DeviceEditView(ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' - default_return_url = 'dcim:device_list' class DeviceDeleteView(ObjectDeleteView): queryset = Device.objects.all() - default_return_url = 'dcim:device_list' class DeviceBulkImportView(BulkImportView): @@ -1168,7 +1115,6 @@ class DeviceBulkImportView(BulkImportView): model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' - default_return_url = 'dcim:device_list' class ChildDeviceBulkImportView(BulkImportView): @@ -1176,7 +1122,6 @@ class ChildDeviceBulkImportView(BulkImportView): model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' - default_return_url = 'dcim:device_list' def _save_obj(self, obj_form, request): @@ -1195,14 +1140,12 @@ class DeviceBulkEditView(BulkEditView): filterset = filters.DeviceFilterSet table = tables.DeviceTable form = forms.DeviceBulkEditForm - default_return_url = 'dcim:device_list' class DeviceBulkDeleteView(BulkDeleteView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable - default_return_url = 'dcim:device_list' # @@ -1210,13 +1153,17 @@ class DeviceBulkDeleteView(BulkDeleteView): # class ConsolePortListView(ObjectListView): - queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = ConsolePort.objects.prefetch_related('device', 'cable') filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm - table = tables.ConsolePortDetailTable + table = tables.ConsolePortTable action_buttons = ('import', 'export') +class ConsolePortView(ObjectView): + queryset = ConsolePort.objects.all() + + class ConsolePortCreateView(ComponentCreateView): queryset = ConsolePort.objects.all() form = forms.ConsolePortCreateForm @@ -1236,8 +1183,7 @@ class ConsolePortDeleteView(ObjectDeleteView): class ConsolePortBulkImportView(BulkImportView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm - table = tables.ConsolePortImportTable - default_return_url = 'dcim:consoleport_list' + table = tables.ConsolePortTable class ConsolePortBulkEditView(BulkEditView): @@ -1247,11 +1193,18 @@ class ConsolePortBulkEditView(BulkEditView): form = forms.ConsolePortBulkEditForm +class ConsolePortBulkRenameView(BulkRenameView): + queryset = ConsolePort.objects.all() + + +class ConsolePortBulkDisconnectView(BulkDisconnectView): + queryset = ConsolePort.objects.all() + + class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable - default_return_url = 'dcim:consoleport_list' # @@ -1259,13 +1212,17 @@ class ConsolePortBulkDeleteView(BulkDeleteView): # class ConsoleServerPortListView(ObjectListView): - queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = ConsoleServerPort.objects.prefetch_related('device', 'cable') filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm - table = tables.ConsoleServerPortDetailTable + table = tables.ConsoleServerPortTable action_buttons = ('import', 'export') +class ConsoleServerPortView(ObjectView): + queryset = ConsoleServerPort.objects.all() + + class ConsoleServerPortCreateView(ComponentCreateView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortCreateForm @@ -1285,8 +1242,7 @@ class ConsoleServerPortDeleteView(ObjectDeleteView): class ConsoleServerPortBulkImportView(BulkImportView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm - table = tables.ConsoleServerPortImportTable - default_return_url = 'dcim:consoleserverport_list' + table = tables.ConsoleServerPortTable class ConsoleServerPortBulkEditView(BulkEditView): @@ -1298,19 +1254,16 @@ class ConsoleServerPortBulkEditView(BulkEditView): class ConsoleServerPortBulkRenameView(BulkRenameView): queryset = ConsoleServerPort.objects.all() - form = forms.ConsoleServerPortBulkRenameForm class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): queryset = ConsoleServerPort.objects.all() - form = forms.ConsoleServerPortBulkDisconnectForm class ConsoleServerPortBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable - default_return_url = 'dcim:consoleserverport_list' # @@ -1318,13 +1271,17 @@ class ConsoleServerPortBulkDeleteView(BulkDeleteView): # class PowerPortListView(ObjectListView): - queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = PowerPort.objects.prefetch_related('device', 'cable') filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm - table = tables.PowerPortDetailTable + table = tables.PowerPortTable action_buttons = ('import', 'export') +class PowerPortView(ObjectView): + queryset = PowerPort.objects.all() + + class PowerPortCreateView(ComponentCreateView): queryset = PowerPort.objects.all() form = forms.PowerPortCreateForm @@ -1344,8 +1301,7 @@ class PowerPortDeleteView(ObjectDeleteView): class PowerPortBulkImportView(BulkImportView): queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm - table = tables.PowerPortImportTable - default_return_url = 'dcim:powerport_list' + table = tables.PowerPortTable class PowerPortBulkEditView(BulkEditView): @@ -1355,11 +1311,18 @@ class PowerPortBulkEditView(BulkEditView): form = forms.PowerPortBulkEditForm +class PowerPortBulkRenameView(BulkRenameView): + queryset = PowerPort.objects.all() + + +class PowerPortBulkDisconnectView(BulkDisconnectView): + queryset = PowerPort.objects.all() + + class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable - default_return_url = 'dcim:powerport_list' # @@ -1367,13 +1330,17 @@ class PowerPortBulkDeleteView(BulkDeleteView): # class PowerOutletListView(ObjectListView): - queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = PowerOutlet.objects.prefetch_related('device', 'cable') filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm - table = tables.PowerOutletDetailTable + table = tables.PowerOutletTable action_buttons = ('import', 'export') +class PowerOutletView(ObjectView): + queryset = PowerOutlet.objects.all() + + class PowerOutletCreateView(ComponentCreateView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletCreateForm @@ -1393,8 +1360,7 @@ class PowerOutletDeleteView(ObjectDeleteView): class PowerOutletBulkImportView(BulkImportView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm - table = tables.PowerOutletImportTable - default_return_url = 'dcim:poweroutlet_list' + table = tables.PowerOutletTable class PowerOutletBulkEditView(BulkEditView): @@ -1406,19 +1372,16 @@ class PowerOutletBulkEditView(BulkEditView): class PowerOutletBulkRenameView(BulkRenameView): queryset = PowerOutlet.objects.all() - form = forms.PowerOutletBulkRenameForm class PowerOutletBulkDisconnectView(BulkDisconnectView): queryset = PowerOutlet.objects.all() - form = forms.PowerOutletBulkDisconnectForm class PowerOutletBulkDeleteView(BulkDeleteView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable - default_return_url = 'dcim:poweroutlet_list' # @@ -1426,10 +1389,10 @@ class PowerOutletBulkDeleteView(BulkDeleteView): # class InterfaceListView(ObjectListView): - queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = Interface.objects.prefetch_related('device', 'cable') filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm - table = tables.InterfaceDetailTable + table = tables.InterfaceTable action_buttons = ('import', 'export') @@ -1451,7 +1414,7 @@ class InterfaceView(ObjectView): if interface.untagged_vlan is not None: vlans.append(interface.untagged_vlan) vlans[0].tagged = False - for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'): + for vlan in interface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'): vlan.tagged = True vlans.append(vlan) vlan_table = InterfaceVLANTable( @@ -1461,7 +1424,7 @@ class InterfaceView(ObjectView): ) return render(request, 'dcim/interface.html', { - 'interface': interface, + 'instance': interface, 'connected_interface': interface._connected_interface, 'connected_circuittermination': interface._connected_circuittermination, 'ipaddress_table': ipaddress_table, @@ -1489,8 +1452,7 @@ class InterfaceDeleteView(ObjectDeleteView): class InterfaceBulkImportView(BulkImportView): queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm - table = tables.InterfaceImportTable - default_return_url = 'dcim:interface_list' + table = tables.InterfaceTable class InterfaceBulkEditView(BulkEditView): @@ -1502,19 +1464,16 @@ class InterfaceBulkEditView(BulkEditView): class InterfaceBulkRenameView(BulkRenameView): queryset = Interface.objects.all() - form = forms.InterfaceBulkRenameForm class InterfaceBulkDisconnectView(BulkDisconnectView): queryset = Interface.objects.all() - form = forms.InterfaceBulkDisconnectForm class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable - default_return_url = 'dcim:interface_list' # @@ -1522,13 +1481,17 @@ class InterfaceBulkDeleteView(BulkDeleteView): # class FrontPortListView(ObjectListView): - queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = FrontPort.objects.prefetch_related('device', 'cable') filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm - table = tables.FrontPortDetailTable + table = tables.FrontPortTable action_buttons = ('import', 'export') +class FrontPortView(ObjectView): + queryset = FrontPort.objects.all() + + class FrontPortCreateView(ComponentCreateView): queryset = FrontPort.objects.all() form = forms.FrontPortCreateForm @@ -1548,8 +1511,7 @@ class FrontPortDeleteView(ObjectDeleteView): class FrontPortBulkImportView(BulkImportView): queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm - table = tables.FrontPortImportTable - default_return_url = 'dcim:frontport_list' + table = tables.FrontPortTable class FrontPortBulkEditView(BulkEditView): @@ -1561,19 +1523,16 @@ class FrontPortBulkEditView(BulkEditView): class FrontPortBulkRenameView(BulkRenameView): queryset = FrontPort.objects.all() - form = forms.FrontPortBulkRenameForm class FrontPortBulkDisconnectView(BulkDisconnectView): queryset = FrontPort.objects.all() - form = forms.FrontPortBulkDisconnectForm class FrontPortBulkDeleteView(BulkDeleteView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable - default_return_url = 'dcim:frontport_list' # @@ -1581,13 +1540,17 @@ class FrontPortBulkDeleteView(BulkDeleteView): # class RearPortListView(ObjectListView): - queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = RearPort.objects.prefetch_related('device', 'cable') filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm - table = tables.RearPortDetailTable + table = tables.RearPortTable action_buttons = ('import', 'export') +class RearPortView(ObjectView): + queryset = RearPort.objects.all() + + class RearPortCreateView(ComponentCreateView): queryset = RearPort.objects.all() form = forms.RearPortCreateForm @@ -1607,8 +1570,7 @@ class RearPortDeleteView(ObjectDeleteView): class RearPortBulkImportView(BulkImportView): queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm - table = tables.RearPortImportTable - default_return_url = 'dcim:rearport_list' + table = tables.RearPortTable class RearPortBulkEditView(BulkEditView): @@ -1620,19 +1582,16 @@ class RearPortBulkEditView(BulkEditView): class RearPortBulkRenameView(BulkRenameView): queryset = RearPort.objects.all() - form = forms.RearPortBulkRenameForm class RearPortBulkDisconnectView(BulkDisconnectView): queryset = RearPort.objects.all() - form = forms.RearPortBulkDisconnectForm class RearPortBulkDeleteView(BulkDeleteView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable - default_return_url = 'dcim:rearport_list' # @@ -1640,15 +1599,17 @@ class RearPortBulkDeleteView(BulkDeleteView): # class DeviceBayListView(ObjectListView): - queryset = DeviceBay.objects.prefetch_related( - 'device', 'device__site', 'installed_device', 'installed_device__site' - ) + queryset = DeviceBay.objects.prefetch_related('device', 'installed_device') filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm - table = tables.DeviceBayDetailTable + table = tables.DeviceBayTable action_buttons = ('import', 'export') +class DeviceBayView(ObjectView): + queryset = DeviceBay.objects.all() + + class DeviceBayCreateView(ComponentCreateView): queryset = DeviceBay.objects.all() form = forms.DeviceBayCreateForm @@ -1735,8 +1696,7 @@ class DeviceBayDepopulateView(ObjectEditView): class DeviceBayBulkImportView(BulkImportView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm - table = tables.DeviceBayImportTable - default_return_url = 'dcim:devicebay_list' + table = tables.DeviceBayTable class DeviceBayBulkEditView(BulkEditView): @@ -1748,14 +1708,67 @@ class DeviceBayBulkEditView(BulkEditView): class DeviceBayBulkRenameView(BulkRenameView): queryset = DeviceBay.objects.all() - form = forms.DeviceBayBulkRenameForm class DeviceBayBulkDeleteView(BulkDeleteView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable - default_return_url = 'dcim:devicebay_list' + + +# +# Inventory items +# + +class InventoryItemListView(ObjectListView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + filterset = filters.InventoryItemFilterSet + filterset_form = forms.InventoryItemFilterForm + table = tables.InventoryItemTable + action_buttons = ('import', 'export') + + +class InventoryItemView(ObjectView): + queryset = InventoryItem.objects.all() + + +class InventoryItemEditView(ObjectEditView): + queryset = InventoryItem.objects.all() + model_form = forms.InventoryItemForm + + +class InventoryItemCreateView(ComponentCreateView): + queryset = InventoryItem.objects.all() + form = forms.InventoryItemCreateForm + model_form = forms.InventoryItemForm + template_name = 'dcim/device_component_add.html' + + +class InventoryItemDeleteView(ObjectDeleteView): + queryset = InventoryItem.objects.all() + + +class InventoryItemBulkImportView(BulkImportView): + queryset = InventoryItem.objects.all() + model_form = forms.InventoryItemCSVForm + table = tables.InventoryItemTable + + +class InventoryItemBulkEditView(BulkEditView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + filterset = filters.InventoryItemFilterSet + table = tables.InventoryItemTable + form = forms.InventoryItemBulkEditForm + + +class InventoryItemBulkRenameView(BulkRenameView): + queryset = InventoryItem.objects.all() + + +class InventoryItemBulkDeleteView(BulkDeleteView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + table = tables.InventoryItemTable + template_name = 'dcim/inventoryitem_bulk_delete.html' # @@ -1850,6 +1863,17 @@ class DeviceBulkAddDeviceBayView(BulkComponentCreateView): default_return_url = 'dcim:device_list' +class DeviceBulkAddInventoryItemView(BulkComponentCreateView): + parent_model = Device + parent_field = 'device' + form = forms.InventoryItemBulkCreateForm + queryset = InventoryItem.objects.all() + model_form = forms.InventoryItemForm + filterset = filters.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + # # Cables # @@ -1891,7 +1915,7 @@ class CableTraceView(ObjectView): def get(self, request, pk): obj = get_object_or_404(self.queryset, pk=pk) - path, split_ends = obj.trace() + path, split_ends, position_stack = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] ) @@ -1900,6 +1924,7 @@ class CableTraceView(ObjectView): 'obj': obj, 'trace': path, 'split_ends': split_ends, + 'position_stack': position_stack, 'total_length': total_length, }) @@ -1907,7 +1932,6 @@ class CableTraceView(ObjectView): class CableCreateView(ObjectEditView): queryset = Cable.objects.all() template_name = 'dcim/cable_connect.html' - default_return_url = 'dcim:cable_list' def dispatch(self, request, *args, **kwargs): @@ -1965,19 +1989,16 @@ class CableEditView(ObjectEditView): queryset = Cable.objects.all() model_form = forms.CableForm template_name = 'dcim/cable_edit.html' - default_return_url = 'dcim:cable_list' class CableDeleteView(ObjectDeleteView): queryset = Cable.objects.all() - default_return_url = 'dcim:cable_list' class CableBulkImportView(BulkImportView): queryset = Cable.objects.all() model_form = forms.CableCSVForm table = tables.CableTable - default_return_url = 'dcim:cable_list' class CableBulkEditView(BulkEditView): @@ -1985,14 +2006,12 @@ class CableBulkEditView(BulkEditView): filterset = filters.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm - default_return_url = 'dcim:cable_list' class CableBulkDeleteView(BulkDeleteView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable - default_return_url = 'dcim:cable_list' # @@ -2096,56 +2115,6 @@ class InterfaceConnectionsListView(ObjectListView): return '\n'.join(csv_data) -# -# Inventory items -# - -class InventoryItemListView(ObjectListView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - filterset = filters.InventoryItemFilterSet - filterset_form = forms.InventoryItemFilterForm - table = tables.InventoryItemTable - action_buttons = ('import', 'export') - - -class InventoryItemEditView(ObjectEditView): - queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemForm - - -class InventoryItemCreateView(ComponentCreateView): - queryset = InventoryItem.objects.all() - form = forms.InventoryItemCreateForm - model_form = forms.InventoryItemForm - template_name = 'dcim/device_component_add.html' - - -class InventoryItemDeleteView(ObjectDeleteView): - queryset = InventoryItem.objects.all() - - -class InventoryItemBulkImportView(BulkImportView): - queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemCSVForm - table = tables.InventoryItemTable - default_return_url = 'dcim:inventoryitem_list' - - -class InventoryItemBulkEditView(BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - filterset = filters.InventoryItemFilterSet - table = tables.InventoryItemTable - form = forms.InventoryItemBulkEditForm - default_return_url = 'dcim:inventoryitem_list' - - -class InventoryItemBulkDeleteView(BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_bulk_delete.html' - default_return_url = 'dcim:inventoryitem_list' - - # # Virtual chassis # @@ -2159,72 +2128,22 @@ class VirtualChassisListView(ObjectListView): class VirtualChassisView(ObjectView): - queryset = VirtualChassis.objects.prefetch_related('members') + queryset = VirtualChassis.objects.all() def get(self, request, pk): virtualchassis = get_object_or_404(self.queryset, pk=pk) + members = Device.objects.restrict(request.user).filter(virtual_chassis=virtualchassis) return render(request, 'dcim/virtualchassis.html', { 'virtualchassis': virtualchassis, + 'members': members, }) -class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View): +class VirtualChassisCreateView(ObjectEditView): queryset = VirtualChassis.objects.all() - - def get_required_permission(self): - return 'dcim.add_virtualchassis' - - def post(self, request): - - # Get the list of devices being added to a VirtualChassis - pk_form = forms.DeviceSelectionForm(request.POST) - pk_form.full_clean() - if not pk_form.cleaned_data.get('pk'): - messages.warning(request, "No devices were selected.") - return redirect('dcim:device_list') - device_queryset = Device.objects.filter( - pk__in=pk_form.cleaned_data.get('pk') - ).prefetch_related('rack').order_by('vc_position') - - VCMemberFormSet = modelformset_factory( - model=Device, - formset=forms.BaseVCMemberFormSet, - form=forms.DeviceVCMembershipForm, - extra=0 - ) - - if '_create' in request.POST: - - vc_form = forms.VirtualChassisForm(request.POST) - vc_form.fields['master'].queryset = device_queryset - formset = VCMemberFormSet(request.POST, queryset=device_queryset) - - if vc_form.is_valid() and formset.is_valid(): - - with transaction.atomic(): - - # Assign each device to the VirtualChassis before saving - virtual_chassis = vc_form.save() - devices = formset.save(commit=False) - for device in devices: - device.virtual_chassis = virtual_chassis - device.save() - - return redirect(vc_form.cleaned_data['master'].get_absolute_url()) - - else: - - vc_form = forms.VirtualChassisForm() - vc_form.fields['master'].queryset = device_queryset - formset = VCMemberFormSet(queryset=device_queryset) - - return render(request, 'dcim/virtualchassis_edit.html', { - 'pk_form': pk_form, - 'vc_form': vc_form, - 'formset': formset, - 'return_url': reverse('dcim:device_list'), - }) + model_form = forms.VirtualChassisCreateForm + template_name = 'dcim/virtualchassis_add.html' class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): @@ -2286,7 +2205,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V for member in members: member.save() - return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + return redirect(virtual_chassis.get_absolute_url()) return render(request, 'dcim/virtualchassis_edit.html', { 'vc_form': vc_form, @@ -2297,7 +2216,6 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V class VirtualChassisDeleteView(ObjectDeleteView): queryset = VirtualChassis.objects.all() - default_return_url = 'dcim:device_list' class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): @@ -2407,19 +2325,23 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL }) +class VirtualChassisBulkImportView(BulkImportView): + queryset = VirtualChassis.objects.all() + model_form = forms.VirtualChassisCSVForm + table = tables.VirtualChassisTable + + class VirtualChassisBulkEditView(BulkEditView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable form = forms.VirtualChassisBulkEditForm - default_return_url = 'dcim:virtualchassis_list' class VirtualChassisBulkDeleteView(BulkDeleteView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable - default_return_url = 'dcim:virtualchassis_list' # @@ -2443,8 +2365,9 @@ class PowerPanelView(ObjectView): def get(self, request, pk): powerpanel = get_object_or_404(self.queryset, pk=pk) + power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=powerpanel).prefetch_related('rack') powerfeed_table = tables.PowerFeedTable( - data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'), + data=power_feeds, orderable=False ) powerfeed_table.exclude = ['power_panel'] @@ -2458,19 +2381,16 @@ class PowerPanelView(ObjectView): class PowerPanelEditView(ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm - default_return_url = 'dcim:powerpanel_list' class PowerPanelDeleteView(ObjectDeleteView): queryset = PowerPanel.objects.all() - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkImportView(BulkImportView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelCSVForm table = tables.PowerPanelTable - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkEditView(BulkEditView): @@ -2478,7 +2398,6 @@ class PowerPanelBulkEditView(BulkEditView): filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkDeleteView(BulkDeleteView): @@ -2489,7 +2408,6 @@ class PowerPanelBulkDeleteView(BulkDeleteView): ) filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable - default_return_url = 'dcim:powerpanel_list' # @@ -2521,19 +2439,16 @@ class PowerFeedEditView(ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm template_name = 'dcim/powerfeed_edit.html' - default_return_url = 'dcim:powerfeed_list' class PowerFeedDeleteView(ObjectDeleteView): queryset = PowerFeed.objects.all() - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkImportView(BulkImportView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedCSVForm table = tables.PowerFeedTable - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkEditView(BulkEditView): @@ -2541,11 +2456,9 @@ class PowerFeedBulkEditView(BulkEditView): filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkDeleteView(BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable - default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 59000f45a..5700efe55 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -110,7 +110,7 @@ class ExportTemplateViewSet(ModelViewSet): # class TagViewSet(ModelViewSet): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( tagged_items=Count('extras_taggeditem_items', distinct=True) ) serializer_class = serializers.TagSerializer diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 9c9c329e3..48da46525 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -6,6 +6,7 @@ from django import get_version from django.apps import apps from django.conf import settings from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -52,6 +53,7 @@ class Command(BaseCommand): pass # Additional objects to include + namespace['ContentType'] = ContentType namespace['User'] = User # Load convenience commands diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index a366187dc..285563b9f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -540,7 +540,7 @@ class ConfigContextModel(models.Model): # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs data = OrderedDict() - for context in ConfigContext.objects.get_for_object(self): + for context in ConfigContext.objects.unrestricted().get_for_object(self): data = deepmerge(data, context.data) # If the object has local config context data defined, merge it last diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index bd49954c9..39ac86073 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -22,14 +22,10 @@ class Tag(TagBase, ChangeLoggedModel): blank=True, ) - objects = models.Manager() - restricted = RestrictedQuerySet.as_manager() + objects = RestrictedQuerySet.as_manager() csv_headers = ['name', 'slug', 'color', 'description'] - def get_absolute_url(self): - return reverse('extras:tag', args=[self.slug]) - def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b4171931d..895475a09 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -173,7 +173,7 @@ class ChoiceVar(ScriptVariable): class ObjectVar(ScriptVariable): """ - NetBox object representation. The provided QuerySet will determine the choices available. + A single object within NetBox. """ form_field = DynamicModelChoiceField diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index c772d3db0..2754bf24b 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,21 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn from .models import ConfigContext, ObjectChange, JobResult, Tag, TaggedItem -TAG_ACTIONS = """ - - - -{% if perms.taggit.change_tag %} - -{% endif %} -{% if perms.taggit.delete_tag %} - -{% endif %} -""" - TAGGED_ITEM = """ {% if value.get_absolute_url %} {{ value }} @@ -64,16 +52,8 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - viewname='extras:tag', - args=[Accessor('slug')] - ) - actions = tables.TemplateColumn( - template_code=TAG_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) color = ColorColumn() + actions = ButtonsColumn(Tag, pk_field='slug') class Meta(BaseTable.Meta): model = Tag diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index b3abf5b22..f14f02e43 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,7 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase -class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag @classmethod diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index a51a3b7e5..f199abfc9 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,7 +13,6 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 10a0b51d6..92453b090 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,13 +4,15 @@ from django import template from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.http import Http404, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2 import RequestConfig +from dcim.models import DeviceRole, Platform, Region, Site +from tenancy.models import Tenant, TenantGroup from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import copy_safe_request, shallow_compare_dict @@ -18,6 +20,7 @@ from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ContentTypePermissionRequiredMixin, ) +from virtualization.models import Cluster, ClusterGroup from . import filters, forms, tables from .choices import JobResultStatusChoices from .models import ConfigContext, ImageAttachment, ObjectChange, Report, JobResult, Script, Tag, TaggedItem @@ -30,7 +33,7 @@ from .scripts import get_scripts, run_script # class TagListView(ObjectListView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' @@ -40,71 +43,39 @@ class TagListView(ObjectListView): table = tables.TagTable -class TagView(ObjectView): - queryset = Tag.restricted.all() - - def get(self, request, slug): - - tag = get_object_or_404(self.queryset, slug=slug) - tagged_items = TaggedItem.objects.filter( - tag=tag - ).prefetch_related( - 'content_type', 'content_object' - ) - - # Generate a table of all items tagged with this Tag - items_table = tables.TaggedItemTable(tagged_items) - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(items_table) - - return render(request, 'extras/tag.html', { - 'tag': tag, - 'items_count': tagged_items.count(), - 'items_table': items_table, - }) - - class TagEditView(ObjectEditView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() model_form = forms.TagForm - default_return_url = 'extras:tag_list' template_name = 'extras/tag_edit.html' class TagDeleteView(ObjectDeleteView): - queryset = Tag.restricted.all() - default_return_url = 'extras:tag_list' + queryset = Tag.objects.all() class TagBulkImportView(BulkImportView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() model_form = forms.TagCSVForm table = tables.TagTable - default_return_url = 'extras:tag_list' class TagBulkEditView(BulkEditView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' ) table = tables.TagTable form = forms.TagBulkEditForm - default_return_url = 'extras:tag_list' class TagBulkDeleteView(BulkDeleteView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items') ).order_by( 'name' ) table = tables.TagTable - default_return_url = 'extras:tag_list' # @@ -123,6 +94,18 @@ class ConfigContextView(ObjectView): queryset = ConfigContext.objects.all() def get(self, request, pk): + # Extend queryset to prefetch related objects + self.queryset = self.queryset.prefetch_related( + Prefetch('regions', queryset=Region.objects.restrict(request.user)), + Prefetch('sites', queryset=Site.objects.restrict(request.user)), + Prefetch('roles', queryset=DeviceRole.objects.restrict(request.user)), + Prefetch('platforms', queryset=Platform.objects.restrict(request.user)), + Prefetch('clusters', queryset=Cluster.objects.restrict(request.user)), + Prefetch('cluster_groups', queryset=ClusterGroup.objects.restrict(request.user)), + Prefetch('tenants', queryset=Tenant.objects.restrict(request.user)), + Prefetch('tenant_groups', queryset=TenantGroup.objects.restrict(request.user)), + ) + configcontext = get_object_or_404(self.queryset, pk=pk) # Determine user's preferred output format @@ -144,7 +127,6 @@ class ConfigContextView(ObjectView): class ConfigContextEditView(ObjectEditView): queryset = ConfigContext.objects.all() model_form = forms.ConfigContextForm - default_return_url = 'extras:configcontext_list' template_name = 'extras/configcontext_edit.html' @@ -153,18 +135,15 @@ class ConfigContextBulkEditView(BulkEditView): filterset = filters.ConfigContextFilterSet table = tables.ConfigContextTable form = forms.ConfigContextBulkEditForm - default_return_url = 'extras:configcontext_list' class ConfigContextDeleteView(ObjectDeleteView): queryset = ConfigContext.objects.all() - default_return_url = 'extras:configcontext_list' class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() table = tables.ConfigContextTable - default_return_url = 'extras:configcontext_list' class ObjectConfigContextView(ObjectView): @@ -264,9 +243,11 @@ class ObjectChangeLogView(View): def get(self, request, model, **kwargs): - # Get object my model and kwargs (e.g. slug='foo') - queryset = model.objects.restrict(request.user, 'view') - obj = get_object_or_404(queryset, **kwargs) + # Handle QuerySet restriction of parent object if needed + if hasattr(model.objects, 'restrict'): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs) + else: + obj = get_object_or_404(model, **kwargs) # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) @@ -299,6 +280,7 @@ class ObjectChangeLogView(View): return render(request, 'extras/object_changelog.html', { object_var: obj, + 'instance': obj, # We'll eventually standardize on 'instance` for the object variable name 'table': objectchanges_table, 'base_template': base_template, 'active_tab': 'changelog', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e92006096..d7f70f113 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,7 @@ 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.reverse import reverse from rest_framework.validators import UniqueTogetherValidator @@ -9,10 +11,12 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) - interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), + required=False + ) + assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) class Meta: model = IPAddress fields = [ - 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside', - 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] read_only_fields = ['family'] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + if obj.assigned_object is None: + return None + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + class AvailableIPSerializer(serializers.Serializer): """ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 60bfade24..2de99dcc1 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_yasg.utils import swagger_auto_schema @@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine', - 'nat_outside', 'tags', + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', ) serializer_class = serializers.IPAddressSerializer filterset_class = filters.IPAddressFilterSet @@ -271,6 +270,9 @@ class VLANViewSet(CustomFieldModelViewSet): # class ServiceViewSet(ModelViewSet): - queryset = Service.objects.prefetch_related('device').prefetch_related('tags') + queryset = Service.objects.prefetch_related( + Prefetch('ipaddresses', queryset=IPAddress.objects.unrestricted()), + 'device', 'virtual_machine', 'tags' + ) serializer_class = serializers.ServiceSerializer filterset_class = filters.ServiceFilterSet diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 41075e54a..1ad355aec 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from .choices import IPAddressRoleChoices # BGP ASN bounds @@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6 # IPAddresses # +IPADDRESS_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='virtualization', model='vminterface') +) + IPADDRESS_MASK_LENGTH_MIN = 1 IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 7662d5825..d6561ceb7 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -11,7 +11,7 @@ from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, field_name='pk', label='Device (ID)', ) - virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine', - queryset=VirtualMachine.objects.unrestricted(), - label='Virtual machine (ID)', - ) - virtual_machine = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine__name', - queryset=VirtualMachine.objects.unrestricted(), - to_field_name='name', + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', label='Virtual machine (name)', ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.unrestricted(), to_field_name='name', - label='Interface (ID)', + label='Interface (name)', ) interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', queryset=Interface.objects.unrestricted(), label='Interface (ID)', ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.unrestricted(), + to_field_name='name', + label='VM interface (name)', + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.unrestricted(), + label='VM interface (ID)', + ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', label='Is assigned to an interface', @@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, return queryset.filter(address__net_mask_length=value) def filter_device(self, queryset, name, value): - try: - devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) - vc_interface_ids = [] - for device in devices: - vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) - return queryset.filter(interface_id__in=vc_interface_ids) - except Device.DoesNotExist: + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces.values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + vminterface__in=interface_ids + ) def _assigned_to_interface(self, queryset, name, value): - return queryset.exclude(interface__isnull=value) + return queryset.exclude(assigned_object_id__isnull=value) class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b332bf33f..596d353bd 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -14,7 +14,7 @@ from utilities.forms import ( ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -522,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) # class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): - interface = forms.ModelChoiceField( + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'interface': 'device_id' + } + ) + ) + interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + widget=APISelect( + filter_for={ + 'vminterface': 'virtual_machine_id' + } + ) + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Interface' + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -597,8 +620,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent', - 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', + 'nat_inside', 'tenant_group', 'tenant', 'tags', ] widgets = { 'status': StaticSelect2(), @@ -610,32 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Initialize helper selectors instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() - if instance and instance.nat_inside and instance.nat_inside.device is not None: - initial['nat_site'] = instance.nat_inside.device.site - initial['nat_rack'] = instance.nat_inside.device.rack - initial['nat_device'] = instance.nat_inside.device + if instance: + if type(instance.assigned_object) is Interface: + initial['device'] = instance.assigned_object.device + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['virtual_machine'] = instance.assigned_object.virtual_machine + initial['vminterface'] = instance.assigned_object + if instance.nat_inside and instance.nat_inside.device is not None: + initial['nat_site'] = instance.nat_inside.device.site + initial['nat_rack'] = instance.nat_inside.device.rack + initial['nat_device'] = instance.nat_inside.device kwargs['initial'] = initial super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' - # Limit interface selections to those belonging to the parent device/VM - if self.instance and self.instance.interface: - self.fields['interface'].queryset = Interface.objects.filter( - device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine - ).prefetch_related( - 'device__primary_ip4', - 'device__primary_ip6', - 'virtual_machine__primary_ip4', - 'virtual_machine__primary_ip6', - ) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save() - else: - self.fields['interface'].choices = [] - # Initialize primary_for_parent if IP address is already assigned - if self.instance.pk and self.instance.interface is not None: - parent = self.instance.interface.parent + if self.instance.pk and self.instance.assigned_object: + parent = self.instance.assigned_object.parent if ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk @@ -645,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel def clean(self): super().clean() + # Cannot select both a device interface and a VM interface + if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): + raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") + # Primary IP assignment is only available if an interface has been assigned. - if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): + interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + if self.cleaned_data.get('primary_for_parent') and not interface: self.add_error( 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) def save(self, *args, **kwargs): + # Set assigned object + interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + if interface: + self.instance.assigned_object = interface + ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. - if self.cleaned_data['primary_for_parent']: - parent = self.cleaned_data['interface'].parent + if interface and self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: - parent.primary_ip4 = ipaddress + interface.parent.primary_ip4 = ipaddress else: - parent.primary_ip6 = ipaddress - parent.save() - elif self.cleaned_data['interface']: - parent = self.cleaned_data['interface'].parent - if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: - parent.primary_ip4 = None - parent.save() - elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: - parent.primary_ip6 = None - parent.save() + interface.primary_ip6 = ipaddress + interface.parent.save() + elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: + interface.parent.primary_ip4 = None + interface.parent.save() + elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: + interface.parent.primary_ip4 = None + interface.parent.save() return ipaddress @@ -742,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): help_text='Parent VM of assigned interface (if any)' ) interface = CSVModelChoiceField( - queryset=Interface.objects.all(), + queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', help_text='Assigned interface' @@ -761,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): if data: - # Limit interface queryset by assigned device or virtual machine + # Limit interface queryset by assigned device if data.get('device'): - params = { - f"device__{self.fields['device'].to_field_name}": data.get('device') - } + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + # Limit interface queryset by assigned device elif data.get('virtual_machine'): - params = { - f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') - } - else: - params = { - 'device': None, - 'virtual_machine': None, - } - self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params) + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) def clean(self): super().clean() @@ -790,6 +810,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): def save(self, *args, **kwargs): + # Set interface assignment + if self.cleaned_data['interface']: + self.instance.assigned_object = self.cleaned_data['interface'] + ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM @@ -1194,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: - vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')] self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface_id__in=vc_interface_ids + interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface__virtual_machine=self.instance.virtual_machine + vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) ) else: self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0037_ipaddress_assignment.py b/netbox/ipam/migrations/0037_ipaddress_assignment.py new file mode 100644 index 000000000..6139d41d6 --- /dev/null +++ b/netbox/ipam/migrations/0037_ipaddress_assignment.py @@ -0,0 +1,40 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def set_assigned_object_type(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + IPAddress = apps.get_model('ipam', 'IPAddress') + + device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk + IPAddress.objects.update(assigned_object_type=device_ct) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0036_standardize_description'), + ] + + operations = [ + migrations.RenameField( + model_name='ipaddress', + old_name='interface', + new_name='assigned_object_id', + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'), + preserve_default=False, + ), + migrations.RunPython( + code=set_assigned_object_type + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b99a6c919..5904178cf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,10 +1,11 @@ import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation -from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from taggit.managers import TaggableManager @@ -14,7 +15,7 @@ from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * from .fields import IPNetworkField, IPAddressField @@ -215,7 +216,9 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): }) # Ensure that the aggregate being added is not covered by an existing aggregate - covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix)) + covering_aggregates = Aggregate.objects.unrestricted().filter( + prefix__net_contains_or_equals=str(self.prefix) + ) if self.pk: covering_aggregates = covering_aggregates.exclude(pk=self.pk) if covering_aggregates: @@ -226,7 +229,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): }) # Ensure that the aggregate being added does not cover an existing aggregate - covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix)) + covered_aggregates = Aggregate.objects.unrestricted().filter(prefix__net_contained=str(self.prefix)) if self.pk: covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: @@ -254,7 +257,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): """ Determine the prefix utilization of the aggregate and return it as a percentage. """ - queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) + queryset = Prefix.objects.unrestricted().filter(prefix__net_contained_or_equal=str(self.prefix)) child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) @@ -552,7 +555,10 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): "container", calculate utilization based on child prefixes. For all others, count child IP addresses. """ if self.status == PrefixStatusChoices.STATUS_CONTAINER: - queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) + queryset = Prefix.objects.unrestricted().filter( + prefix__net_contained=str(self.prefix), + vrf=self.vrf + ) child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) else: @@ -606,13 +612,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): blank=True, help_text='The functional role of this IP' ) - interface = models.ForeignKey( - to='dcim.Interface', - on_delete=models.CASCADE, - related_name='ip_addresses', + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', blank=True, null=True ) + assigned_object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) nat_inside = models.OneToOneField( to='self', on_delete=models.SET_NULL, @@ -643,11 +658,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): objects = IPAddressManager() csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', + 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary', 'dns_name', 'description', ] clone_fields = [ - 'vrf', 'tenant', 'status', 'role', 'description', 'interface', + 'vrf', 'tenant', 'status', 'role', 'description', ] STATUS_CLASS_MAP = { @@ -707,32 +722,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): ) }) - if self.pk: - - # Check for primary IP assignment that doesn't match the assigned device/VM - device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + # Check for primary IP assignment that doesn't match the assigned device/VM + if self.pk and type(self.assigned_object) is Interface: + device = Device.objects.unrestricted().filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if device: - if self.interface is None: + if self.assigned_object is None: raise ValidationError({ - 'interface': "IP address is primary for device {} but not assigned".format(device) + 'interface': f"IP address is primary for device {device} but not assigned to an interface" }) - elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device: + elif self.assigned_object.device != device: raise ValidationError({ - 'interface': "IP address is primary for device {} but assigned to {} ({})".format( - device, self.interface.device, self.interface - ) + 'interface': f"IP address is primary for device {device} but assigned to " + f"{self.assigned_object.device} ({self.assigned_object})" }) - vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + elif self.pk and type(self.assigned_object) is VMInterface: + vm = VirtualMachine.unrestricted().objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if vm: - if self.interface is None: + if self.assigned_object is None: raise ValidationError({ - 'interface': "IP address is primary for virtual machine {} but not assigned".format(vm) + 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " + f"interface" }) - elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm: + elif self.interface.virtual_machine != vm: raise ValidationError({ - 'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format( - vm, self.interface.virtual_machine, self.interface - ) + 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to " + f"{self.assigned_object.virtual_machine} ({self.assigned_object})" }) def save(self, *args, **kwargs): @@ -743,29 +757,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned Interface (if any) - try: - parent_obj = self.interface - except ObjectDoesNotExist: - parent_obj = None - + # Annotate the assigned object, if any return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent_obj, + related_object=self.assigned_object, object_data=serialize_object(self) ) def to_csv(self): # Determine if this IP is primary for a Device + is_primary = False if self.address.version == 4 and getattr(self, 'primary_ip4_for', False): is_primary = True elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False): is_primary = True - else: - is_primary = False + + obj_type = None + if self.assigned_object_type: + obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}' return ( self.address, @@ -773,9 +785,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.tenant.name if self.tenant else None, self.get_status_display(), self.get_role_display(), - self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.interface.name if self.interface else None, + obj_type, + self.assigned_object_id, is_primary, self.dns_name, self.description, @@ -796,18 +807,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.address.prefixlen = value mask_length = property(fset=_set_mask_length) - @property - def device(self): - if self.interface: - return self.interface.device - return None - - @property - def virtual_machine(self): - if self.interface: - return self.interface.virtual_machine - return None - def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ca48c2951..72ac3eb45 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """ @@ -25,15 +25,6 @@ RIR_UTILIZATION = """ """ -RIR_ACTIONS = """ - - - -{% if perms.ipam.change_rir %} - -{% endif %} -""" - UTILIZATION_GRAPH = """ {% load helpers %} {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} @@ -47,15 +38,6 @@ ROLE_VLAN_COUNT = """ {{ value }} """ -ROLE_ACTIONS = """ - - - -{% if perms.ipam.change_role %} - -{% endif %} -""" - PREFIX_LINK = """ {% if record.has_children %} @@ -92,14 +74,6 @@ IPADDRESS_ASSIGN_LINK = """ {% endif %} """ -IPADDRESS_PARENT = """ -{% if record.interface %} - {{ record.interface.parent }} -{% else %} - — -{% endif %} -""" - VRF_LINK = """ {% if record.vrf %} {{ record.vrf }} @@ -144,10 +118,7 @@ VLAN_ROLE_LINK = """ {% endif %} """ -VLANGROUP_ACTIONS = """ - - - +VLANGROUP_ADD_VLAN = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -155,9 +126,6 @@ VLANGROUP_ACTIONS = """ {% endif %} {% endwith %} -{% if perms.ipam.change_vlangroup %} - -{% endif %} """ VLAN_MEMBER_UNTAGGED = """ @@ -168,7 +136,7 @@ VLAN_MEMBER_UNTAGGED = """ VLAN_MEMBER_ACTIONS = """ {% if perms.dcim.change_interface %} - + {% endif %} """ @@ -222,11 +190,7 @@ class RIRTable(BaseTable): aggregate_count = tables.Column( verbose_name='Aggregates' ) - actions = tables.TemplateColumn( - template_code=RIR_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RIR, pk_field='slug') class Meta(BaseTable.Meta): model = RIR @@ -330,11 +294,7 @@ class RoleTable(BaseTable): orderable=False, verbose_name='VLANs' ) - actions = tables.TemplateColumn( - template_code=ROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Role, pk_field='slug') class Meta(BaseTable.Meta): model = Role @@ -431,18 +391,14 @@ class IPAddressTable(BaseTable): tenant = tables.TemplateColumn( template_code=TENANT_LINK ) - parent = tables.TemplateColumn( - template_code=IPADDRESS_PARENT, - orderable=False - ) - interface = tables.Column( - orderable=False + assigned = tables.BooleanColumn( + accessor='assigned_object_id' ) class Meta(BaseTable.Meta): model = IPAddress fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', @@ -465,11 +421,11 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', 'tags', ) default_columns = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) @@ -481,17 +437,13 @@ class IPAddressAssignTable(BaseTable): status = tables.TemplateColumn( template_code=STATUS_LABEL ) - parent = tables.TemplateColumn( - template_code=IPADDRESS_PARENT, - orderable=False - ) - interface = tables.Column( + assigned_object = tables.Column( orderable=False ) class Meta(BaseTable.Meta): model = IPAddress - fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') + fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description') orderable = False @@ -532,10 +484,9 @@ class VLANGroupTable(BaseTable): vlan_count = tables.Column( verbose_name='VLANs' ) - actions = tables.TemplateColumn( - template_code=VLANGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' + actions = ButtonsColumn( + model=VLANGroup, + prepend_template=VLANGROUP_ADD_VLAN ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 70ae738b5..d7b6b3a7b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -416,7 +416,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): """ Attempt and fail to delete a VLAN with a Prefix assigned to it. """ - vlan = VLAN.objects.first() + vlan = VLAN.objects.unrestricted().first() Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vlan=vlan) self.add_permissions('ipam.delete_vlan') diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 785f5f2c5..560313f0a 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase): ) Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='Interface 1'), + Interface(device=devices[1], name='Interface 2'), + Interface(device=devices[2], name='Interface 3'), + ) + Interface.objects.bulk_create(interfaces) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') @@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase): ) VirtualMachine.objects.bulk_create(virtual_machines) - interfaces = ( - Interface(device=devices[0], name='Interface 1'), - Interface(device=devices[1], name='Interface 2'), - Interface(device=devices[2], name='Interface 3'), - Interface(virtual_machine=virtual_machines[0], name='Interface 1'), - Interface(virtual_machine=virtual_machines[1], name='Interface 2'), - Interface(virtual_machine=virtual_machines[2], name='Interface 3'), + vminterfaces = ( + VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'), + VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'), + VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(vminterfaces) tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), @@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase): Tenant.objects.bulk_create(tenants) ipaddresses = ( - IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), - IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), ) IPAddress.objects.bulk_create(ipaddresses) @@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase): params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'interface': ['Interface 1', 'Interface 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vminterface(self): + vminterfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vminterface': ['Interface 1', 'Interface 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 6091aa70e..51d1d9684 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -43,7 +43,7 @@ class TestPrefix(TestCase): Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), )) - duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()] + duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates().unrestricted()] self.assertSetEqual(set(duplicate_prefix_pks), {prefixes[1].pk, prefixes[2].pk}) @@ -227,7 +227,7 @@ class TestIPAddress(TestCase): IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), )) - duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()] + duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates().unrestricted()] self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk}) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 06090e768..eb7f05e8f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'status': IPAddressStatusChoices.STATUS_RESERVED, 'role': IPAddressRoleChoices.ROLE_ANYCAST, - 'interface': None, 'nat_inside': None, 'dns_name': 'example', 'description': 'A new IP address', diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index de8fc86eb..b2080c0a8 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates @@ -43,6 +44,7 @@ urlpatterns = [ path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes @@ -77,6 +79,7 @@ urlpatterns = [ path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py new file mode 100644 index 000000000..f3cc0cb52 --- /dev/null +++ b/netbox/ipam/utils.py @@ -0,0 +1,93 @@ +import netaddr + +from .constants import * +from .models import Prefix, VLAN + + +def add_available_prefixes(parent, prefix_list): + """ + Create fake Prefix objects for all unallocated space within a prefix. + """ + + # Find all unallocated space + available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) + available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()] + + # Concatenate and sort complete list of children + prefix_list = list(prefix_list) + available_prefixes + prefix_list.sort(key=lambda p: p.prefix) + + return prefix_list + + +def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False): + """ + Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be + considered usable (regardless of mask length). + """ + + output = [] + prev_ip = None + + # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31. + if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool: + first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) + last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) + else: + first_ip_in_prefix = netaddr.IPAddress(prefix.first) + last_ip_in_prefix = netaddr.IPAddress(prefix.last) + + if not ipaddress_list: + return [( + int(last_ip_in_prefix - first_ip_in_prefix + 1), + '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) + )] + + # Account for any available IPs before the first real IP + if ipaddress_list[0].address.ip > first_ip_in_prefix: + skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix) + first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) + output.append((skipped_count, first_skipped)) + + # Iterate through existing IPs and annotate free ranges + for ip in ipaddress_list: + if prev_ip: + diff = int(ip.address.ip - prev_ip.address.ip) + if diff > 1: + first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) + output.append((diff - 1, first_skipped)) + output.append(ip) + prev_ip = ip + + # Include any remaining available IPs + if prev_ip.address.ip < last_ip_in_prefix: + skipped_count = int(last_ip_in_prefix - prev_ip.address.ip) + first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) + output.append((skipped_count, first_skipped)) + + return output + + +def add_available_vlans(vlan_group, vlans): + """ + Create fake records for all gaps between used VLANs + """ + if not vlans: + return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}] + + prev_vid = VLAN_VID_MAX + new_vlans = [] + for vlan in vlans: + if vlan.vid - prev_vid > 1: + new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1}) + prev_vid = vlan.vid + + if vlans[0].vid > VLAN_VID_MIN: + new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN}) + if prev_vid < VLAN_VID_MAX: + new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid}) + + vlans = list(vlans) + new_vlans + vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) + + return vlans diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 98fe1d73d..5c0df3a16 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,6 @@ import netaddr from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Count, Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django_tables2 import RequestConfig @@ -11,100 +11,12 @@ from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF - - -def add_available_prefixes(parent, prefix_list): - """ - Create fake Prefix objects for all unallocated space within a prefix. - """ - - # Find all unallocated space - available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) - available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()] - - # Concatenate and sort complete list of children - prefix_list = list(prefix_list) + available_prefixes - prefix_list.sort(key=lambda p: p.prefix) - - return prefix_list - - -def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False): - """ - Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be - considered usable (regardless of mask length). - """ - - output = [] - prev_ip = None - - # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31. - if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool: - first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) - last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) - else: - first_ip_in_prefix = netaddr.IPAddress(prefix.first) - last_ip_in_prefix = netaddr.IPAddress(prefix.last) - - if not ipaddress_list: - return [( - int(last_ip_in_prefix - first_ip_in_prefix + 1), - '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) - )] - - # Account for any available IPs before the first real IP - if ipaddress_list[0].address.ip > first_ip_in_prefix: - skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix) - first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) - output.append((skipped_count, first_skipped)) - - # Iterate through existing IPs and annotate free ranges - for ip in ipaddress_list: - if prev_ip: - diff = int(ip.address.ip - prev_ip.address.ip) - if diff > 1: - first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) - output.append((diff - 1, first_skipped)) - output.append(ip) - prev_ip = ip - - # Include any remaining available IPs - if prev_ip.address.ip < last_ip_in_prefix: - skipped_count = int(last_ip_in_prefix - prev_ip.address.ip) - first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) - output.append((skipped_count, first_skipped)) - - return output - - -def add_available_vlans(vlan_group, vlans): - """ - Create fake records for all gaps between used VLANs - """ - if not vlans: - return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}] - - prev_vid = VLAN_VID_MAX - new_vlans = [] - for vlan in vlans: - if vlan.vid - prev_vid > 1: - new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1}) - prev_vid = vlan.vid - - if vlans[0].vid > VLAN_VID_MIN: - new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN}) - if prev_vid < VLAN_VID_MAX: - new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid}) - - vlans = list(vlans) + new_vlans - vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) - - return vlans +from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans # @@ -136,19 +48,16 @@ class VRFEditView(ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' - default_return_url = 'ipam:vrf_list' class VRFDeleteView(ObjectDeleteView): queryset = VRF.objects.all() - default_return_url = 'ipam:vrf_list' class VRFBulkImportView(BulkImportView): queryset = VRF.objects.all() model_form = forms.VRFCSVForm table = tables.VRFTable - default_return_url = 'ipam:vrf_list' class VRFBulkEditView(BulkEditView): @@ -156,14 +65,12 @@ class VRFBulkEditView(BulkEditView): filterset = filters.VRFFilterSet table = tables.VRFTable form = forms.VRFBulkEditForm - default_return_url = 'ipam:vrf_list' class VRFBulkDeleteView(BulkDeleteView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable - default_return_url = 'ipam:vrf_list' # @@ -196,10 +103,12 @@ class RIRListView(ObjectListView): 'deprecated': 0, 'available': 0, } - aggregate_list = Aggregate.objects.filter(prefix__family=family, rir=rir) + aggregate_list = Aggregate.objects.restrict(request.user).filter(prefix__family=family, rir=rir) for aggregate in aggregate_list: - queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix)) + queryset = Prefix.objects.restrict(request.user).filter( + prefix__net_contained_or_equal=str(aggregate.prefix) + ) # Find all consumed space for each prefix status (we ignore containers for this purpose). active_prefixes = netaddr.cidr_merge( @@ -249,21 +158,22 @@ class RIRListView(ObjectListView): class RIREditView(ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm - default_return_url = 'ipam:rir_list' + + +class RIRDeleteView(ObjectDeleteView): + queryset = RIR.objects.all() class RIRBulkImportView(BulkImportView): queryset = RIR.objects.all() model_form = forms.RIRCSVForm table = tables.RIRTable - default_return_url = 'ipam:rir_list' class RIRBulkDeleteView(BulkDeleteView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet table = tables.RIRTable - default_return_url = 'ipam:rir_list' # @@ -345,19 +255,16 @@ class AggregateEditView(ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' - default_return_url = 'ipam:aggregate_list' class AggregateDeleteView(ObjectDeleteView): queryset = Aggregate.objects.all() - default_return_url = 'ipam:aggregate_list' class AggregateBulkImportView(BulkImportView): queryset = Aggregate.objects.all() model_form = forms.AggregateCSVForm table = tables.AggregateTable - default_return_url = 'ipam:aggregate_list' class AggregateBulkEditView(BulkEditView): @@ -365,14 +272,12 @@ class AggregateBulkEditView(BulkEditView): filterset = filters.AggregateFilterSet table = tables.AggregateTable form = forms.AggregateBulkEditForm - default_return_url = 'ipam:aggregate_list' class AggregateBulkDeleteView(BulkDeleteView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable - default_return_url = 'ipam:aggregate_list' # @@ -387,20 +292,21 @@ class RoleListView(ObjectListView): class RoleEditView(ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm - default_return_url = 'ipam:role_list' + + +class RoleDeleteView(ObjectDeleteView): + queryset = Role.objects.all() class RoleBulkImportView(BulkImportView): queryset = Role.objects.all() model_form = forms.RoleCSVForm table = tables.RoleTable - default_return_url = 'ipam:role_list' class RoleBulkDeleteView(BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable - default_return_url = 'ipam:role_list' # @@ -517,7 +423,7 @@ class PrefixIPAddressesView(ObjectView): # Find all IPAddresses belonging to this Prefix ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' + 'vrf', 'primary_ip4_for', 'primary_ip6_for' ) # Add available IP addresses to the table if requested @@ -556,20 +462,17 @@ class PrefixEditView(ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' - default_return_url = 'ipam:prefix_list' class PrefixDeleteView(ObjectDeleteView): queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' - default_return_url = 'ipam:prefix_list' class PrefixBulkImportView(BulkImportView): queryset = Prefix.objects.all() model_form = forms.PrefixCSVForm table = tables.PrefixTable - default_return_url = 'ipam:prefix_list' class PrefixBulkEditView(BulkEditView): @@ -577,14 +480,12 @@ class PrefixBulkEditView(BulkEditView): filterset = filters.PrefixFilterSet table = tables.PrefixTable form = forms.PrefixBulkEditForm - default_return_url = 'ipam:prefix_list' class PrefixBulkDeleteView(BulkDeleteView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable - default_return_url = 'ipam:prefix_list' # @@ -593,7 +494,7 @@ class PrefixBulkDeleteView(BulkDeleteView): class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' + 'vrf__tenant', 'tenant', 'nat_inside' ) filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm @@ -622,7 +523,7 @@ class IPAddressView(ObjectView): ).exclude( pk=ipaddress.pk ).prefetch_related( - 'nat_inside', 'interface__device' + 'nat_inside' ) # Exclude anycast IPs if this IP is anycast if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: @@ -630,9 +531,7 @@ class IPAddressView(ObjectView): duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related( - 'interface__device' - ).exclude( + related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( address=str(ipaddress.address) ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) @@ -657,17 +556,21 @@ class IPAddressEditView(ObjectEditView): queryset = IPAddress.objects.all() model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' - default_return_url = 'ipam:ipaddress_list' def alter_obj(self, obj, request, url_args, url_kwargs): - interface_id = request.GET.get('interface') - if interface_id: + if 'interface' in request.GET: try: - obj.interface = Interface.objects.get(pk=interface_id) + obj.assigned_object = Interface.objects.get(pk=request.GET['interface']) except (ValueError, Interface.DoesNotExist): pass + elif 'vminterface' in request.GET: + try: + obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface']) + except (ValueError, VMInterface.DoesNotExist): + pass + return obj @@ -699,9 +602,7 @@ class IPAddressAssignView(ObjectView): if form.is_valid(): - addresses = self.queryset.prefetch_related( - 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' - ) + addresses = self.queryset.prefetch_related('vrf', 'tenant') # Limit to 100 results addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100] table = tables.IPAddressAssignTable(addresses) @@ -715,37 +616,33 @@ class IPAddressAssignView(ObjectView): class IPAddressDeleteView(ObjectDeleteView): queryset = IPAddress.objects.all() - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkCreateView(BulkCreateView): + queryset = IPAddress.objects.all() form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkImportView(BulkImportView): queryset = IPAddress.objects.all() model_form = forms.IPAddressCSVForm table = tables.IPAddressTable - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkEditView(BulkEditView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkDeleteView(BulkDeleteView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable - default_return_url = 'ipam:ipaddress_list' # @@ -762,21 +659,22 @@ class VLANGroupListView(ObjectListView): class VLANGroupEditView(ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm - default_return_url = 'ipam:vlangroup_list' + + +class VLANGroupDeleteView(ObjectDeleteView): + queryset = VLANGroup.objects.all() class VLANGroupBulkImportView(BulkImportView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm table = tables.VLANGroupTable - default_return_url = 'ipam:vlangroup_list' class VLANGroupBulkDeleteView(BulkDeleteView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet table = tables.VLANGroupTable - default_return_url = 'ipam:vlangroup_list' class VLANGroupVLANsView(ObjectView): @@ -785,7 +683,9 @@ class VLANGroupVLANsView(ObjectView): def get(self, request, pk): vlan_group = get_object_or_404(self.queryset, pk=pk) - vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk) + vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk).prefetch_related( + Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) + ) vlans = add_available_vlans(vlan_group, vlans) vlan_table = tables.VLANDetailTable(vlans) @@ -871,19 +771,16 @@ class VLANEditView(ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' - default_return_url = 'ipam:vlan_list' class VLANDeleteView(ObjectDeleteView): queryset = VLAN.objects.all() - default_return_url = 'ipam:vlan_list' class VLANBulkImportView(BulkImportView): queryset = VLAN.objects.all() model_form = forms.VLANCSVForm table = tables.VLANTable - default_return_url = 'ipam:vlan_list' class VLANBulkEditView(BulkEditView): @@ -891,14 +788,12 @@ class VLANBulkEditView(BulkEditView): filterset = filters.VLANFilterSet table = tables.VLANTable form = forms.VLANBulkEditForm - default_return_url = 'ipam:vlan_list' class VLANBulkDeleteView(BulkDeleteView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable - default_return_url = 'ipam:vlan_list' # @@ -945,7 +840,6 @@ class ServiceBulkImportView(BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable - default_return_url = 'ipam:service_list' class ServiceDeleteView(ObjectDeleteView): @@ -957,11 +851,9 @@ class ServiceBulkEditView(BulkEditView): filterset = filters.ServiceFilterSet table = tables.ServiceTable form = forms.ServiceBulkEditForm - default_return_url = 'ipam:service_list' class ServiceBulkDeleteView(BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable - default_return_url = 'ipam:service_list' diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 10d2d1b09..7381ca685 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -24,7 +24,7 @@ class ObjectPermissionBackend(ModelBackend): Return all permissions granted to the user by an ObjectPermission. """ # Retrieve all assigned ObjectPermissions - object_permissions = ObjectPermission.objects.filter( + object_permissions = ObjectPermission.objects.unrestricted().filter( Q(users=user_obj) | Q(groups__user=user_obj) ).prefetch_related('object_types') diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index eefac2c41..e753dd637 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -208,6 +208,10 @@ PLUGINS = [] # prefer IPv4 instead. PREFER_IPV4 = False +# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 + # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bce482214..0c0f4506d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,6 +99,8 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7e5ce89b7..3ae203c29 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -168,356 +168,6 @@ class ExternalAuthenticationTestCase(TestCase): self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) -class ObjectPermissionViewTestCase(TestCase): - - @classmethod - def setUpTestData(cls): - - cls.sites = ( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3'), - ) - Site.objects.bulk_create(cls.sites) - - cls.prefixes = ( - Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), - ) - Prefix.objects.bulk_create(cls.prefixes) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object(self): - - # Attempt to retrieve object without permission - response = self.client.get(self.prefixes[0].get_absolute_url()) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Retrieve permitted object - response = self.client.get(self.prefixes[0].get_absolute_url()) - self.assertHttpStatus(response, 200) - - # Attempt to retrieve non-permitted object - response = self.client.get(self.prefixes[3].get_absolute_url()) - self.assertHttpStatus(response, 404) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - - # Attempt to list objects without permission - response = self.client.get(reverse('ipam:prefix_list')) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Retrieve all objects. Only permitted objects should be returned. - response = self.client.get(reverse('ipam:prefix_list')) - self.assertHttpStatus(response, 200) - self.assertIn(str(self.prefixes[0].prefix), str(response.content)) - self.assertNotIn(str(self.prefixes[3].prefix), str(response.content)) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object(self): - initial_count = Prefix.objects.count() - form_data = { - 'prefix': '10.0.9.0/24', - 'site': self.sites[1].pk, - 'status': PrefixStatusChoices.STATUS_ACTIVE, - } - - # Attempt to create an object without permission - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': False, # Do not follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - self.assertEqual(initial_count, Prefix.objects.count()) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'add'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to create a non-permitted object - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count) - - # Create a permitted object - form_data['site'] = self.sites[0].pk - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count + 1) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object(self): - form_data = { - 'prefix': '10.0.9.0/24', - 'site': self.sites[0].pk, - 'status': PrefixStatusChoices.STATUS_RESERVED, - } - - # Attempt to edit an object without permission - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': False, # Do not follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'change'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to edit a non-permitted object - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 404) - - # Edit a permitted object - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - prefix = Prefix.objects.get(pk=self.prefixes[0].pk) - self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object(self): - form_data = { - 'confirm': True - } - - # Attempt to delete object without permission - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'delete'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Delete permitted object - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) - - # Attempt to delete non-permitted object - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 404) - self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_import_objects(self): - initial_count = Prefix.objects.count() - form_data = { - 'csv': "prefix,status,site\n" - "10.0.9.0/24,Active,Site 1\n" - "10.0.10.0/24,Active,Site 2\n" - "10.0.11.0/24,Active,Site 3\n", - } - - # Attempt to import objects without permission - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - self.assertEqual(initial_count, Prefix.objects.count()) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['add'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to create non-permitted objects - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count) - - # Create a permitted object - form_data = { - 'csv': "prefix,status,site\n" - "10.0.9.0/24,Active,Site 1\n" - "10.0.10.0/24,Active,Site 1\n" - "10.0.11.0/24,Active,Site 1\n", - } - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count + 3) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_edit_objects(self): - form_data = { - 'pk': [p.pk for p in self.prefixes], - 'status': 'reserved', - '_apply': True, - } - - # Attempt to edit objects without permission - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['change'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to edit non-permitted objects - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active') - - # Edit permitted objects - form_data['pk'] = [p.pk for p in self.prefixes[:3]] - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved') - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects(self): - form_data = { - 'pk': [p.pk for p in self.prefixes], - 'confirm': True, - '_confirm': True, - } - - # Attempt to delete objects without permission - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'delete'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to delete non-permitted object - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) - - # Delete permitted objects - form_data['pk'] = [p.pk for p in self.prefixes[:3]] - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) - - class ObjectPermissionAPIViewTestCase(TestCase): client_class = APIClient diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 1bba7cd67..9a7ad35ab 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -183,13 +183,6 @@ nav ul.pagination { margin-bottom: 8px !important; } -/* Racks */ -div.rack_header { - margin-left: 32px; - text-align: center; - width: 220px; -} - /* Devices */ table.component-list td.subtable { padding: 0; diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index f92c9216b..f773a278f 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,17 +1,8 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import SecretRole, Secret -SECRETROLE_ACTIONS = """ - - - -{% if perms.secrets.change_secretrole %} - -{% endif %} -""" - # # Secret roles @@ -23,11 +14,7 @@ class SecretRoleTable(BaseTable): secret_count = tables.Column( verbose_name='Secrets' ) - actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(SecretRole, pk_field='slug') class Meta(BaseTable.Meta): model = SecretRole diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 84c2da398..9dbb5d044 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index a5aabaecd..e9ea1835f 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -36,20 +36,21 @@ class SecretRoleListView(ObjectListView): class SecretRoleEditView(ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm - default_return_url = 'secrets:secretrole_list' + + +class SecretRoleDeleteView(ObjectDeleteView): + queryset = SecretRole.objects.all() class SecretRoleBulkImportView(BulkImportView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' class SecretRoleBulkDeleteView(BulkDeleteView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' # @@ -147,7 +148,6 @@ class SecretEditView(ObjectEditView): class SecretDeleteView(ObjectDeleteView): queryset = Secret.objects.all() - default_return_url = 'secrets:secret_list' class SecretBulkImportView(BulkImportView): @@ -155,7 +155,6 @@ class SecretBulkImportView(BulkImportView): model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' - default_return_url = 'secrets:secret_list' widget_attrs = {'class': 'requires-session-key'} master_key = None @@ -203,11 +202,9 @@ class SecretBulkEditView(BulkEditView): filterset = filters.SecretFilterSet table = tables.SecretTable form = forms.SecretBulkEditForm - default_return_url = 'secrets:secret_list' class SecretBulkDeleteView(BulkDeleteView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable - default_return_url = 'secrets:secret_list' diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 1e7210e9a..df484609a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -88,6 +88,16 @@ + {% elif position_stack %} +
+

+ {% with last_position=position_stack|last %} + Trace completed, but there is no Front Port corresponding to + {{ last_position.device }} {{ last_position }}.
+ Therefore no end-to-end connection can be established. + {% endwith %} +

+
{% else %}

Trace completed!

diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html new file mode 100644 index 000000000..63916bcc5 --- /dev/null +++ b/netbox/templates/dcim/consoleport.html @@ -0,0 +1,103 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
+ Console Port +
+ + + + + + + + + + + + + + + + + + + + + +
Device + {{ instance.device }} +
Name{{ instance.name }}
Label{{ instance.label|placeholder }}
Type{{ instance.get_type_display }}
Description{{ instance.description|placeholder }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
+
+
+
+ Connection +
+ {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
Device + {{ instance.connected_endpoint.device }} +
Name + {{ instance.connected_endpoint.name }} +
Type{{ instance.connected_endpoint.get_type_display|placeholder }}
Description{{ instance.connected_endpoint.description|placeholder }}
Cable + {{ instance.cable }} + + + +
Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
+ {% else %} +
+ Not connected +
+ {% endif %} +
+ {% plugin_right_page instance %} +
+
+
+
+ {% plugin_full_width_page instance %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html new file mode 100644 index 000000000..cdc43142e --- /dev/null +++ b/netbox/templates/dcim/consoleserverport.html @@ -0,0 +1,103 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
+ Console Server Port +
+ + + + + + + + + + + + + + + + + + + + + +
Device + {{ instance.device }} +
Name{{ instance.name }}
Label{{ instance.label|placeholder }}
Type{{ instance.get_type_display }}
Description{{ instance.description|placeholder }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
+
+
+
+ Connection +
+ {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
Device + {{ instance.connected_endpoint.device }} +
Name + {{ instance.connected_endpoint.name }} +
Type{{ instance.connected_endpoint.get_type_display|placeholder }}
Description{{ instance.connected_endpoint.description|placeholder }}
Cable + {{ instance.cable }} + + + +
Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
+ {% else %} +
+ Not connected +
+ {% endif %} +
+ {% plugin_right_page instance %} +
+
+
+
+ {% plugin_full_width_page instance %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a42250a3d..01f125db4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -74,6 +74,9 @@ {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %} +
  • Inventory Items
  • + {% endif %}
    {% endif %} @@ -326,34 +329,85 @@ {% plugin_left_page device %}
    - {% if console_ports or power_ports %} -
    -
    - Console / Power -
    - - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% endfor %} -
    - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - + + {% endif %} + {% if power_ports %} +
    + {% csrf_token %} +
    +
    + Power Ports +
    + + {% for pp in power_ports %} + {% include 'dcim/inc/powerport.html' %} + {% endfor %} +
    + +
    +
    {% endif %} {% if power_ports and poweroutlets %}
    @@ -501,262 +555,242 @@
    {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
    + {% csrf_token %} - {% endif %} -
    -
    - Device Bays -
    - - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} +
    +
    + Device Bays +
    +
    NameStatusDescriptionInstalled Device
    + - + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + - {% endfor %} - -
    — No device bays defined —NameStatusDescriptionInstalled Device
    - -
    - {% if perms.dcim.delete_devicebay %} -
    - {% endif %} + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + — No device bays defined — + + {% endfor %} + + + +
    + {% endif %} {% if interfaces %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Interfaces -
    - -
    -
    - -
    -
    - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% endfor %} - -
    NameLAGDescriptionMTUModeCableConnection
    - + {% endif %} {% if consoleserverports %} - {% if perms.dcim.delete_consoleserverport %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Console Server Ports +
    +
    + Console Server Ports +
    + + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + + + + {% for csp in consoleserverports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% endfor %} + +
    NameTypeDescriptionCableConnection
    +
    - - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - - - - {% for csp in consoleserverports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% endfor %} - -
    NameTypeDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_consoleserverport %} - - {% endif %} + {% endif %} {% if poweroutlets %} - {% if perms.dcim.delete_poweroutlet %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Power Outlets +
    +
    + Power Outlets +
    + + + + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + + + + + + + + + {% for po in poweroutlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% endfor %} + +
    NameTypeInput/LegDescriptionCableConnection
    +
    - - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - - - - - {% for po in poweroutlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% endfor %} - -
    NameTypeInput/LegDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_poweroutlet %} - - {% endif %} + {% endif %} {% if front_ports %}
    {% csrf_token %} -
    Front Ports @@ -815,7 +849,6 @@ {% if rear_ports %} {% csrf_token %} -
    Rear Ports diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html new file mode 100644 index 000000000..9fa66502e --- /dev/null +++ b/netbox/templates/dcim/device_component.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block header %} + +
    + {% plugin_buttons instance %} + {% if request.user|can_change:instance %} + + Edit + + {% endif %} + {% if request.user|can_delete:instance %} + + Delete + + {% endif %} +
    +

    {% block title %}{{ instance.device }} / {{ instance }}{% endblock %}

    + +{% endblock %} diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1a7e5d793..69afbb6a1 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -5,61 +5,61 @@ {% block content %}
    -
    -
    -
    - Chassis -
    - - - - - - - - - - - - - -
    Model{{ device.device_type.display_name }}
    Serial Number{{ device.serial|placeholder }}
    Asset Tag{{ device.asset_tag|placeholder }}
    -
    -
    -
    -
    -
    - Hardware -
    - - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
    NameManufacturerPart IDSerial NumberAsset TagDescription
    - {% if perms.dcim.add_inventoryitem %} -
    {% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index ebee21d18..b1cd32eea 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -14,12 +14,8 @@ {% if perms.dcim.add_interface %}
  • Interfaces
  • {% endif %} {% if perms.dcim.add_rearport %}
  • Rear Ports
  • {% endif %} {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %}
  • Inventory Items
  • {% endif %}
    {% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html new file mode 100644 index 000000000..3d65e8bde --- /dev/null +++ b/netbox/templates/dcim/devicebay.html @@ -0,0 +1,70 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Device Bay +
    + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Installed Device +
    + {% if instance.installed_device %} + {% with device=instance.installed_device %} + + + + + + + + + +
    Device + {{ device }} +
    Device Type{{ device.device_type }}
    + {% endwith %} + {% else %} +
    + None +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 2479d58d2..7ca29a1f7 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -131,7 +131,7 @@ Instances - {{ devicetype.instances.count }} + {{ instance_count }}
    @@ -173,7 +173,7 @@ {% if devicetype.is_parent_device or devicebay_table.rows %}
    - {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url=None delete_url='dcim:devicebaytemplate_bulk_delete' %} + {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url='dcim:devicebaytemplate_bulk_edit' delete_url='dcim:devicebaytemplate_bulk_delete' %}
    {% endif %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html new file mode 100644 index 000000000..8ab51cb30 --- /dev/null +++ b/netbox/templates/dcim/frontport.html @@ -0,0 +1,91 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Front Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Rear Port + {{ instance.rear_port }} +
    Rear Port Position{{ instance.rear_port_position }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + + + + + + + + +
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.cable.status %} + {{ instance.cable.get_status_display }} + {% else %} + {{ instance.cable.get_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0711ff121..1ba3d05c9 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -16,7 +16,9 @@ Component - {{ termination }} + + {{ termination }} + {% else %} {# Circuit termination #} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 9089f19b4..61b4fe045 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,8 +1,16 @@ + {# Checkbox #} + {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} + + + + {% endif %} + {# Name #} - {{ cp }} + + {{ cp }} {# Type #} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 0d649f812..dcf168ae7 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -11,7 +11,8 @@ {# Name #} - {{ csp }} + + {{ csp }} {# Type #} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 70ce7e8df..ee6a66d8f 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -9,7 +9,8 @@ {# Name #} - {{ devicebay.name }} + + {{ devicebay.name }} {# Status #} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 12915f64d..f267479f3 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -10,7 +10,8 @@ {# Name #} - {{ frontport }} + + {{ frontport }} {# Type #} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 2fe970fd7..75869db52 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -166,7 +166,7 @@ {% endif %} - + {% endif %} @@ -176,7 +176,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 56ccfeace..1b103893f 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -1,11 +1,34 @@ +{% load helpers %} - {{ item }} - {% if not item.discovered %}{% endif %} - {{ item.manufacturer|default:"" }} - {{ item.part_id }} - {{ item.serial }} - {{ item.asset_tag|default:"" }} - {{ item.description }} + + {# Checkbox #} + {% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %} + + + + {% endif %} + + + {{ item }} + + + {% if item.manufacturer %} + {{ item.manufacturer }} + {% else %} + + {% endif %} + + {{ item.part_id|placeholder }} + {{ item.serial|placeholder }} + {{ item.asset_tag|placeholder }} + + {% if item.discovered %} + + {% else %} + + {% endif %} + + {{ item.description|placeholder }} {% if perms.dcim.change_inventoryitem %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 1c0630310..d9a77d647 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -11,7 +11,8 @@ {# Name #} - {{ po }} + + {{ po }} {# Type #} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 045b25dfd..58eed145a 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -1,8 +1,16 @@ + {# Checkbox #} + {% if perms.dcim.change_powerport or perms.dcim.delete_powerport %} + + + + {% endif %} + {# Name #} - {{ pp }} + + {{ pp }} {# Type #} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index db5a134c6..a13a3900e 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,4 +1,6 @@ - +
    + +
    Save SVG diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 73ccd6b70..c1e5482d0 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -10,7 +10,8 @@ {# Name #} - {{ rearport }} + + {{ rearport }} {# Type #} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5714c8940..e3d67eb2c 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -1,258 +1,227 @@ -{% extends 'base.html' %} +{% extends 'dcim/device_component.html' %} {% load helpers %} - -{% block header %} -
    -
    - -
    -
    -
    - {% if perms.dcim.change_interface %} - - Edit - - {% endif %} - {% if perms.dcim.delete_interface %} - - Delete - - {% endif %} -
    -

    {% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

    - -{% endblock %} +{% load plugins %} {% block content %} -
    -
    -
    -
    - Interface -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% if interface.device %}Device{% else %}Virtual Machine{% endif %} - {{ interface.parent }} -
    Name{{ interface.name }}
    Label{{ interface.label|placeholder }}
    Type{{ interface.get_type_display }}
    Enabled - {% if interface.enabled %} - - {% else %} - - {% endif %} -
    LAG - {% if interface.lag%} - {{ interface.lag }} - {% else %} - None - {% endif %} -
    Description{{ interface.description|placeholder }}
    MTU{{ interface.mtu|placeholder }}
    MAC Address{{ interface.mac_address|placeholder }}
    802.1Q Mode{{ interface.get_mode_display }}
    -
    - {% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %} -
    -
    - {% if interface.is_connectable %} +
    +
    - Connection + Interface
    - {% if interface.cable %} - - {% if connected_interface %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% elif connected_circuittermination %} - {% with ct=connected_circuittermination %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - - - - - -
    Device - {{ connected_interface.device }} -
    Name - {{ connected_interface.name }} -
    Type{{ connected_interface.get_type_display }}
    Enabled - {% if connected_interface.enabled %} - - {% else %} - - {% endif %} -
    LAG - {% if connected_interface.lag%} - {{ connected_interface.lag }} - {% else %} - None - {% endif %} -
    Description{{ connected_interface.description|placeholder }}
    MTU{{ connected_interface.mtu|placeholder }}
    MAC Address{{ connected_interface.mac_address|placeholder }}
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Cable - {{ interface.cable }} - - - -
    Connection Status - {% if interface.connection_status %} - {{ interface.get_connection_status_display }} - {% else %} - {{ interface.get_connection_status_display }} - {% endif %} -
    - {% else %} -
    - Not connected -
    - {% endif %} -
    - {% endif %} - {% if interface.is_lag %} -
    -
    LAG Members
    - - - - - - - - - - {% for member in interface.member_interfaces.all %} - - - - - - {% empty %} - - - - {% endfor %} - +
    ParentInterfaceType
    - {{ member.parent }} - - {{ member }} - - {{ member.get_type_display }} -
    No member interfaces
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Enabled + {% if instance.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if instance.lag%} + {{ instance.lag }} + {% else %} + None + {% endif %} +
    Description{{ instance.description|placeholder }}
    MTU{{ instance.mtu|placeholder }}
    MAC Address{{ instance.mac_address|placeholder }}
    802.1Q Mode{{ instance.get_mode_display }}
    - {% endif %} + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    + {% if instance.is_connectable %} +
    +
    + Connection +
    + {% if instance.cable %} + + {% if connected_interface %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% elif connected_circuittermination %} + {% with ct=connected_circuittermination %} + + + + + + + + + + + + + {% endwith %} + {% endif %} + + + + + + + + +
    Device + {{ connected_interface.device }} +
    Name + {{ connected_interface.name }} +
    Type{{ connected_interface.get_type_display }}
    Enabled + {% if connected_interface.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if connected_interface.lag%} + {{ connected_interface.lag }} + {% else %} + None + {% endif %} +
    Description{{ connected_interface.description|placeholder }}
    MTU{{ connected_interface.mtu|placeholder }}
    MAC Address{{ connected_interface.mac_address|placeholder }}
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% endif %} + {% if instance.is_lag %} +
    +
    LAG Members
    + + + + + + + + + + {% for member in instance.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
    ParentInterfaceType
    + {{ member.device }} + + {{ member }} + + {{ member.get_type_display }} +
    No member interfaces
    +
    + {% endif %} + {% plugin_right_page instance %} +
    -
    -
    -
    - {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    -
    -
    -
    - {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    -
    {% endblock %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html new file mode 100644 index 000000000..6e12d9f33 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem.html @@ -0,0 +1,73 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Inventory Item +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Parent Item + {% if instance.parent %} + {{ instance.parent }} + {% else %} + + {% endif %} +
    Name{{ instance.name }}
    Manufacturer + {% if instance.manufacturer %} + {{ instance.manufacturer }} + {% else %} + + {% endif %} +
    Part ID{{ instance.part_id|placeholder }}
    Serial{{ instance.serial|placeholder }}
    Asset Tag{{ instance.asset_tag|placeholder }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html new file mode 100644 index 000000000..cddcffd6f --- /dev/null +++ b/netbox/templates/dcim/poweroutlet.html @@ -0,0 +1,111 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Power Outlet +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    Power Port{{ instance.power_port }}
    Feed Leg{{ instance.get_feed_leg_display }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html new file mode 100644 index 000000000..8642bd8fb --- /dev/null +++ b/netbox/templates/dcim/powerport.html @@ -0,0 +1,111 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Power Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    Maximum Draw{{ instance.maximum_draw|placeholder }}
    Allocated Draw{{ instance.allocated_draw|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 8d63a7095..6aff957e4 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -318,16 +318,12 @@
    -
    -
    -

    Front

    -
    +
    +

    Front

    {% include 'dcim/inc/rack_elevation.html' with face='front' %}
    -
    -
    -

    Rear

    -
    +
    +

    Rear

    {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
    diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html new file mode 100644 index 000000000..982d53eaa --- /dev/null +++ b/netbox/templates/dcim/rearport.html @@ -0,0 +1,85 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Rear Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Positions{{ instance.positions }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + + + + + + + + +
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.cable.status %} + {{ instance.cable.get_status_display }} + {% else %} + {{ instance.cable.get_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index a97c42e4f..18ec8c4e7 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -9,7 +9,9 @@
    @@ -63,7 +65,17 @@ Domain {{ virtualchassis.domain|placeholder }} - + + + Master + + {% if virtualchassis.master %} + {{ virtualchassis.master }} + {% else %} + + {% endif %} + +
    {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} @@ -81,7 +93,7 @@ Master Priority - {% for vc_member in virtualchassis.members.all %} + {% for vc_member in members %} {{ vc_member }} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..07b17f378 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,22 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    Virtual Chassis
    +
    + {% render_field form.name %} + {% render_field form.domain %} + {% render_field form.tags %} +
    +
    +
    +
    Member Devices
    +
    + {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.members %} + {% render_field form.initial_position %} +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html index 7eeab8ec9..2024d4ab7 100644 --- a/netbox/templates/extras/inc/tags_panel.html +++ b/netbox/templates/extras/inc/tags_panel.html @@ -4,7 +4,7 @@ Tags
    - {% for tag in tags.unrestricted %} + {% for tag in tags.all %} {% tag tag url %} {% empty %} No tags assigned diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index db8442821..23808bc04 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -144,6 +144,12 @@ Platforms + {% if perms.dcim.add_virtualchassis %} +
    + + +
    + {% endif %} Virtual Chassis
  • @@ -167,16 +173,6 @@ Manufacturers
  • - - - {% if perms.dcim.add_inventoryitem %} -
    - -
    - {% endif %} - Inventory Items - -
  • {% if perms.dcim.add_cable %} @@ -261,6 +257,14 @@ {% endif %} Device Bays + + {% if perms.dcim.add_inventoryitem %} +
    + +
    + {% endif %} + Inventory Items + + + {% if perms.virtualization.add_vminterface %} +
    + +
    + {% endif %} + Interfaces +
  • diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index fe9177f87..c0baef070 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -19,21 +19,21 @@ {% endif %} -
    - {% for k, v_list in request.GET.lists %} - {% if k != 'per_page' %} - {% for v in v_list %} - - {% endfor %} - {% endif %} - {% endfor %} - per page -
    {% endif %} +
    + {% for k, v_list in request.GET.lists %} + {% if k != 'per_page' %} + {% for v in v_list %} + + {% endfor %} + {% endif %} + {% endfor %} + per page +
    {% if page %}
    Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 6eba1a5e6..ff83061cf 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -120,8 +120,8 @@ Assignment - {% if ipaddress.interface %} - {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) + {% if ipaddress.assigned_object %} + {{ ipaddress.assigned_object.parent }} ({{ ipaddress.assigned_object }}) {% else %} {% endif %} @@ -132,8 +132,8 @@ {% if ipaddress.nat_inside %} {{ ipaddress.nat_inside }} - {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.parent }}) + {% if ipaddress.nat_inside.assigned_object %} + ({{ ipaddress.nat_inside.assigned_object.parent }}) {% endif %} {% else %} None diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d8902595a..4e2706daf 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -28,25 +28,30 @@ {% render_field form.tenant %}
    - {% if obj.interface %} -
    -
    - Interface Assignment -
    -
    -
    - -
    -

    - {{ obj.interface.parent }} -

    +
    +
    + Interface Assignment +
    +
    + {% with vm_tab_active=obj.vminterface.exists %} + +
    +
    + {% render_field form.device %} + {% render_field form.interface %} +
    +
    + {% render_field form.virtual_machine %} + {% render_field form.vminterface %}
    - {% render_field form.interface %} - {% render_field form.primary_for_parent %} -
    + {% endwith %} + {% render_field form.primary_for_parent %}
    - {% endif %} +
    NAT IP (Inside)
    diff --git a/netbox/templates/dcim/bulk_rename.html b/netbox/templates/utilities/obj_bulk_rename.html similarity index 100% rename from netbox/templates/dcim/bulk_rename.html rename to netbox/templates/utilities/obj_bulk_rename.html diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 85ff050ed..47f11e1c1 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -9,10 +9,10 @@ {% endif %} {% if permissions.add and 'add' in action_buttons %} - {% add_button content_type.model_class|url_name:"add" %} + {% add_button content_type.model_class|validated_viewname:"add" %} {% endif %} {% if permissions.add and 'import' in action_buttons %} - {% import_button content_type.model_class|url_name:"import" %} + {% import_button content_type.model_class|validated_viewname:"import" %} {% endif %} {% if 'export' in action_buttons %} {% export_button content_type %} @@ -21,7 +21,7 @@

    {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}

    - {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} {% if permissions.change or permissions.delete %}
    {% csrf_token %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html new file mode 100644 index 000000000..5410fba7a --- /dev/null +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -0,0 +1,141 @@ +{% load helpers %} + + + {# Checkbox #} + {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + + + + {% endif %} + + {# Name #} + + {{ iface }} + + + {# MAC address #} + + {{ iface.mac_address|default:"—" }} + + + {# MTU #} + {{ iface.mtu|default:"—" }} + + {# 802.1Q mode #} + {{ iface.get_mode_display|default:"—" }} + + {# Description/tags #} + + {% if iface.description %} + {{ iface.description }}
    + {% endif %} + {% for tag in iface.tags.all %} + {% tag tag %} + {% empty %} + {% if not iface.description %}—{% endif %} + {% endfor %} + + + {# Buttons #} + + {% if show_interface_graphs %} + + {% endif %} + {% if perms.ipam.add_ipaddress %} + + + + {% endif %} + {% if perms.virtualization.change_interface %} + + + + {% endif %} + {% if perms.virtualization.delete_interface %} + + + + {% endif %} + + + +{% with ipaddresses=iface.ip_addresses.all %} + {% if ipaddresses %} + + {# Placeholder #} + {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + + {% endif %} + + {# IP addresses table #} + + + + + + + + + + + + {% for ip in iface.ip_addresses.all %} + + + {# IP address #} + + + {# Primary/status/role #} + + + {# VRF #} + + + {# Description #} + + + {# Buttons #} + + + + {% endfor %} +
    IP AddressStatus/RoleVRFDescription
    + {{ ip }} + + {% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %} + Primary + {% endif %} + {{ ip.get_status_display }} + {% if ip.role %} + {{ ip.get_role_display }} + {% endif %} + + {% if ip.vrf %} + {{ ip.vrf.name }} + {% else %} + Global + {% endif %} + + {% if ip.description %} + {{ ip.description }} + {% else %} + + {% endif %} + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} +
    + + + {% endif %} +{% endwith %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea8f4fedb..9cc206dac 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -248,7 +248,7 @@
    - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} {% csrf_token %} @@ -268,22 +268,20 @@ - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} {% endif %} - - + - - + {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' with device=virtualmachine %} + {% include 'virtualization/inc/vminterface.html' %} {% empty %} @@ -291,24 +289,24 @@ {% endfor %}
    NameLAGDescriptionMAC Address MTU ModeCableConnectionDescription
    — No interfaces defined —
    - {% if perms.dcim.add_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} {% endif %}
    - {% if perms.dcim.delete_interface %} + {% if perms.virtualization.delete_vminterface %} {% endif %}
    diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index 34a8f3c3d..aafefffa1 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -22,12 +22,6 @@ {{ component_type|bettertitle }}
    -
    - -
    -

    {{ parent }}

    -
    -
    {% render_form form %}
    diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 74839b250..f8ee77626 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -7,7 +7,7 @@ Add Components
    {% endif %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html new file mode 100644 index 000000000..8d46b52fd --- /dev/null +++ b/netbox/templates/virtualization/vminterface.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    + {% if perms.virtualization.change_vminterface %} + + Edit + + {% endif %} + {% if perms.virtualization.delete_vminterface %} + + Delete + + {% endif %} +
    +

    {% block title %}{{ vminterface.virtual_machine }} / {{ vminterface.name }}{% endblock %}

    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Interface +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Virtual Machine + {{ vminterface.virtual_machine }} +
    Name{{ vminterface.name }}
    Enabled + {% if vminterface.enabled %} + + {% else %} + + {% endif %} +
    Description{{ vminterface.description|placeholder }}
    MTU{{ vminterface.mtu|placeholder }}
    MAC Address{{ vminterface.mac_address|placeholder }}
    802.1Q Mode{{ vminterface.get_mode_display }}
    +
    +
    +
    + {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/vminterface_edit.html similarity index 90% rename from netbox/templates/virtualization/interface_edit.html rename to netbox/templates/virtualization/vminterface_edit.html index 437b960c9..6b0313284 100644 --- a/netbox/templates/virtualization/interface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -21,7 +21,7 @@ {% block buttons %} {% if obj.pk %} - + {% else %} diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 147a20707..dc96b839c 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup MPTT_LINK = """ @@ -13,15 +13,6 @@ MPTT_LINK = """ """ -TENANTGROUP_ACTIONS = """ - - - -{% if perms.tenancy.change_tenantgroup %} - -{% endif %} -""" - COL_TENANT = """ {% if record.tenant %} {{ record.tenant }} @@ -44,11 +35,7 @@ class TenantGroupTable(BaseTable): tenant_count = tables.Column( verbose_name='Tenants' ) - actions = tables.TemplateColumn( - template_code=TENANTGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(TenantGroup, pk_field='slug') class Meta(BaseTable.Meta): model = TenantGroup diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 4c65ce4e8..372308bb8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index a82b231f5..9ef44206c 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -30,20 +30,21 @@ class TenantGroupListView(ObjectListView): class TenantGroupEditView(ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm - default_return_url = 'tenancy:tenantgroup_list' + + +class TenantGroupDeleteView(ObjectDeleteView): + queryset = TenantGroup.objects.all() class TenantGroupBulkImportView(BulkImportView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' class TenantGroupBulkDeleteView(BulkDeleteView): queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' # @@ -87,19 +88,16 @@ class TenantEditView(ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' - default_return_url = 'tenancy:tenant_list' class TenantDeleteView(ObjectDeleteView): queryset = Tenant.objects.all() - default_return_url = 'tenancy:tenant_list' class TenantBulkImportView(BulkImportView): queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' class TenantBulkEditView(BulkEditView): @@ -107,11 +105,9 @@ class TenantBulkEditView(BulkEditView): filterset = filters.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm - default_return_url = 'tenancy:tenant_list' class TenantBulkDeleteView(BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' diff --git a/netbox/users/admin.py b/netbox/users/admin.py index cc7a1b379..25703966c 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,7 +1,7 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import Group as StockGroup, User as StockUser +from django.contrib.auth.models import Group, User from django.core.exceptions import FieldError, ValidationError from extras.admin import order_content_types @@ -13,8 +13,8 @@ from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig # # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below -admin.site.unregister(StockGroup) -admin.site.unregister(StockUser) +admin.site.unregister(Group) +admin.site.unregister(User) @admin.register(AdminGroup) diff --git a/netbox/users/models.py b/netbox/users/models.py index 7987ccb7a..bce3bd704 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -159,6 +159,7 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) +@receiver(post_save, sender=AdminUser) def create_userconfig(instance, created, **kwargs): """ Automatically create a new UserConfig when a new User is created. diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 50401dfd1..067af9e5a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -254,8 +254,11 @@ class WritableNestedSerializer(ModelSerializer): # Dictionary of related object attributes if isinstance(data, dict): params = dict_to_filter_params(data) + queryset = self.Meta.model.objects + if hasattr(queryset, 'restrict'): + queryset = queryset.unrestricted() try: - return self.Meta.model.objects.get(**params) + return queryset.get(**params) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided attributes: {}".format(params) @@ -281,8 +284,11 @@ class WritableNestedSerializer(ModelSerializer): ) # Look up object by PK + queryset = self.Meta.model.objects + if hasattr(queryset, 'restrict'): + queryset = queryset.unrestricted() try: - return self.Meta.model.objects.get(pk=int(data)) + return queryset.get(pk=int(data)) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided numeric ID: {}".format(pk) @@ -327,7 +333,7 @@ class ModelViewSet(_ModelViewSet): def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) - if not request.user.is_authenticated or request.user.is_superuser: + if not request.user.is_authenticated: return # TODO: Reconcile this with TokenPermissions.perms_map diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 14463de23..38297838d 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -5,14 +5,8 @@ from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField -from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer from extras.api.customfields import CustomFieldsSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer -from virtualization.api.serializers import InterfaceSerializer as VirtualMachineInterfaceSerializer - -# this might be ugly, but it limits drf_yasg-specific code to this file -DeviceInterfaceSerializer.Meta.ref_name = 'DeviceInterface' -VirtualMachineInterfaceSerializer.Meta.ref_name = 'VirtualMachineInterface' class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 4eb19f539..a9b851def 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -68,6 +68,6 @@ class NaturalOrderingField(models.CharField): return ( self.name, 'utilities.fields.NaturalOrderingField', - ['target_field'], + [self.target_field], kwargs, ) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..b13c55f40 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -102,7 +102,7 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): kwargs.setdefault('field_name', 'tags__slug') kwargs.setdefault('to_field_name', 'slug') kwargs.setdefault('conjoined', True) - kwargs.setdefault('queryset', Tag.objects.all()) + kwargs.setdefault('queryset', Tag.objects.unrestricted()) super().__init__(*args, **kwargs) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 3ac954a82..851efd24f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -15,6 +15,7 @@ from django.forms import BoundField from django.forms.models import fields_for_model from django.urls import reverse +from utilities.querysets import RestrictedQuerySet from .choices import ColorChoices, unpack_grouped_choices from .validators import EnhancedURLValidator @@ -138,6 +139,16 @@ def form_from_model(model, fields): return type('FormFromModel', (forms.Form,), form_fields) +def restrict_form_fields(form, user, action='view'): + """ + Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects + as available choices. + """ + for field in form.fields.values(): + if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): + field.queryset = field.queryset.restrict(user, action) + + # # Widgets # @@ -518,8 +529,8 @@ class ExpandableNameField(forms.CharField): """ def to_python(self, value): - if value is None: - return list() + if not value: + return '' if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): return list(expand_alphanumeric_pattern(value)) return [value] @@ -585,7 +596,7 @@ class TagFilterField(forms.MultipleChoiceField): def __init__(self, model, *args, **kwargs): def get_choices(): - tags = model.tags.annotate( + tags = model.tags.all().unrestricted().annotate( count=Count('extras_taggeditem_items') ).order_by('name') return [ @@ -733,6 +744,30 @@ class BulkEditForm(forms.Form): self.nullable_fields = self.Meta.nullable_fields +class BulkRenameForm(forms.Form): + """ + An extendable form to be used for renaming objects in bulk. + """ + find = forms.CharField() + replace = forms.CharField() + use_regex = forms.BooleanField( + required=False, + initial=True, + label='Use regular expressions' + ) + + def clean(self): + + # Validate regular expression in "find" field + if self.cleaned_data['use_regex']: + try: + re.compile(self.cleaned_data['find']) + except re.error: + raise forms.ValidationError({ + 'find': "Invalid regular expression" + }) + + class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. @@ -795,31 +830,6 @@ class ImportForm(BootstrapMixin, forms.Form): }) -class LabeledComponentForm(BootstrapMixin, forms.Form): - """ - Base form for adding label pattern validation to `Create` forms - """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) - - def clean(self): - - # Validate that the number of components being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} components, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }, code='label_pattern_mismatch') - - class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py new file mode 100644 index 000000000..8fd664d5a --- /dev/null +++ b/netbox/utilities/metadata.py @@ -0,0 +1,19 @@ +from rest_framework.metadata import SimpleMetadata +from django.utils.encoding import force_str +from utilities.api import ContentTypeField + + +class ContentTypeMetadata(SimpleMetadata): + + def get_field_info(self, field): + field_info = super().get_field_info(field) + if hasattr(field, 'queryset') and not field_info.get('read_only') and isinstance(field, ContentTypeField): + field_info['choices'] = [ + { + 'value': choice_value, + 'display_name': force_str(choice_name, strings_only=True) + } + for choice_value, choice_name in field.choices.items() + ] + field_info['choices'].sort(key=lambda item: item['display_name']) + return field_info diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 04bc4c542..5a867c04d 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -59,13 +59,13 @@ class RestrictedQuerySet(QuerySet): self.allow_evaluation = True return self - def restrict(self, user, action): + def restrict(self, user, action='view'): """ Filter the QuerySet to return only objects on which the specified user has been granted the specified permission. :param user: User instance - :param action: The action which must be permitted (e.g. "view" for "dcim.view_site") + :param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view' """ # Resolve the full name of the required permission app_label = self.model._meta.app_label diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 5e277e633..ec3d5dff5 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -123,6 +123,49 @@ class BooleanColumn(tables.Column): return mark_safe(rendered) +class ButtonsColumn(tables.TemplateColumn): + """ + Render edit, delete, and changelog buttons for an object. + + :param model: Model class to use for calculating URL view names + :param prepend_content: Additional template content to render in the column (optional) + """ + attrs = {'td': {'class': 'text-right text-nowrap noprint'}} + # Note that braces are escaped to allow for string formatting prior to template rendering + template_code = """ + + + + {{% if perms.{app_label}.change_{model_name} %}} + + + + {{% endif %}} + {{% if perms.{app_label}.delete_{model_name} %}} + + + + {{% endif %}} + """ + + def __init__(self, model, *args, pk_field='pk', prepend_template=None, **kwargs): + if prepend_template: + prepend_template = prepend_template.replace('{', '{{') + prepend_template = prepend_template.replace('}', '}}') + self.template_code = prepend_template + self.template_code + + template_code = self.template_code.format( + app_label=model._meta.app_label, + model_name=model._meta.model_name, + pk_field=pk_field + ) + + super().__init__(template_code=template_code, *args, **kwargs) + + def header(self): + return '' + + class ColorColumn(tables.Column): """ Display a color (#RRGGBB). diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a70e917d8..e6a245a04 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -75,16 +75,24 @@ def meta(obj, attr): @register.filter() -def url_name(model, action): +def viewname(model, action): """ - Return the URL name for the given model and action, or None if invalid. + Return the view name for the given model and action. Does not perform any validation. """ - url_name = '{}:{}_{}'.format(model._meta.app_label, model._meta.model_name, action) + return f'{model._meta.app_label}:{model._meta.model_name}_{action}' + + +@register.filter() +def validated_viewname(model, action): + """ + Return the view name for the given model and action if valid, or None if invalid. + """ + viewname = f'{model._meta.app_label}:{model._meta.model_name}_{action}' try: - # Validate and return the URL name. We don't return the actual URL yet because many of the templates + # Validate and return the view name. We don't return the actual URL yet because many of the templates # are written to pass a name to {% url %}. - reverse(url_name) - return url_name + reverse(viewname) + return viewname except NoReverseMatch: return None diff --git a/netbox/utilities/templatetags/perms.py b/netbox/utilities/templatetags/perms.py new file mode 100644 index 000000000..f1bbf7549 --- /dev/null +++ b/netbox/utilities/templatetags/perms.py @@ -0,0 +1,30 @@ +from django import template + +register = template.Library() + + +def _check_permission(user, instance, action): + return user.has_perm( + perm=f'{instance._meta.app_label}.{action}_{instance._meta.model_name}', + obj=instance + ) + + +@register.filter() +def can_view(user, instance): + return _check_permission(user, instance, 'view') + + +@register.filter() +def can_add(user, instance): + return _check_permission(user, instance, 'add') + + +@register.filter() +def can_change(user, instance): + return _check_permission(user, instance, 'change') + + +@register.filter() +def can_delete(user, instance): + return _check_permission(user, instance, 'delete') diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index ce4f1d1e5..fdaef4b4b 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -52,7 +52,7 @@ class APIViewTestCases: """ GET a single object as an unauthenticated user. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) response = self.client.get(url, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -61,7 +61,7 @@ class APIViewTestCases: """ GET a single object as an authenticated user without the required permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) # Try GET without permission with disable_warnings('django.request'): @@ -72,9 +72,9 @@ class APIViewTestCases: """ GET a single object as an authenticated user with permission to view the object. """ - self.assertGreaterEqual(self.model.objects.count(), 2, + self.assertGreaterEqual(self.model.objects.unrestricted().count(), 2, f"Test requires the creation of at least two {self.model} instances") - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self.model.objects.unrestricted()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -104,7 +104,7 @@ class APIViewTestCases: url = self._get_list_url() response = self.client.get(url, **self.header) - self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertEqual(len(response.data['results']), self.model.objects.unrestricted().count()) self.assertHttpStatus(response, status.HTTP_200_OK) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @@ -115,7 +115,7 @@ class APIViewTestCases: url = f'{self._get_list_url()}?brief=1' response = self.client.get(url, **self.header) - self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertEqual(len(response.data['results']), self.model.objects.unrestricted().count()) self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @@ -134,9 +134,9 @@ class APIViewTestCases: """ GET a list of objects as an authenticated user with permission to view the objects. """ - self.assertGreaterEqual(self.model.objects.count(), 3, + self.assertGreaterEqual(self.model.objects.unrestricted().count(), 3, f"Test requires the creation of at least three {self.model} instances") - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self.model.objects.unrestricted()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -178,11 +178,15 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - initial_count = self.model.objects.count() + initial_count = self.model.objects.unrestricted().count() response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), initial_count + 1) - self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) + self.assertEqual(self.model.objects.unrestricted().count(), initial_count + 1) + self.assertInstanceEqual( + self.model.objects.unrestricted().get(pk=response.data['id']), + self.create_data[0], + api=True + ) def test_bulk_create_objects(self): """ @@ -196,13 +200,17 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - initial_count = self.model.objects.count() + initial_count = self.model.objects.unrestricted().count() response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(len(response.data), len(self.create_data)) - self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) + self.assertEqual(self.model.objects.unrestricted().count(), initial_count + len(self.create_data)) for i, obj in enumerate(response.data): - self.assertInstanceEqual(self.model.objects.get(pk=obj['id']), self.create_data[i], api=True) + self.assertInstanceEqual( + self.model.objects.unrestricted().get(pk=obj['id']), + self.create_data[i], + api=True + ) class UpdateObjectViewTestCase(APITestCase): update_data = {} @@ -211,7 +219,7 @@ class APIViewTestCases: """ PATCH a single object without permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) update_data = self.update_data or getattr(self, 'create_data')[0] # Try PATCH without permission @@ -223,7 +231,7 @@ class APIViewTestCases: """ PATCH a single object identified by its numeric ID. """ - instance = self.model.objects.first() + instance = self.model.objects.unrestricted().first() url = self._get_detail_url(instance) update_data = self.update_data or getattr(self, 'create_data')[0] @@ -246,7 +254,7 @@ class APIViewTestCases: """ DELETE a single object without permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) # Try DELETE without permission with disable_warnings('django.request'): @@ -257,7 +265,7 @@ class APIViewTestCases: """ DELETE a single object identified by its numeric ID. """ - instance = self.model.objects.first() + instance = self.model.objects.unrestricted().first() url = self._get_detail_url(instance) # Add object-level permission @@ -270,7 +278,7 @@ class APIViewTestCases: response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertFalse(self.model.objects.filter(pk=instance.pk).exists()) + self.assertFalse(self.model.objects.unrestricted().filter(pk=instance.pk).exists()) class APIViewTestCase( GetObjectViewTestCase, diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 774ceac85..b4ca52246 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -165,6 +165,14 @@ class ModelViewTestCase(TestCase): if self.model is None: raise Exception("Test case requires model to be defined") + def _get_queryset(self): + """ + Return a base queryset suitable for use in test methods. Call unrestricted() if RestrictedQuerySet is in use. + """ + if hasattr(self.model.objects, 'restrict'): + return self.model.objects.unrestricted() + return self.model.objects.all() + def _get_base_url(self): """ Return the base format for a URL for the test's model. Override this to test for a model which belongs @@ -177,27 +185,23 @@ class ModelViewTestCase(TestCase): def _get_url(self, action, instance=None): """ - Return the URL name for a specific action. An instance must be specified for - get/edit/delete views. + Return the URL name for a specific action and optionally a specific instance """ url_format = self._get_base_url() - if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): + # If no instance was provided, assume we don't need a unique identifier + if instance is None: return reverse(url_format.format(action)) - elif action in ('get', 'edit', 'delete'): - if instance is None: - raise Exception("Resolving {} URL requires specifying an instance".format(action)) - # Attempt to resolve using slug first - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - return reverse(url_format.format(action), kwargs={'pk': instance.pk}) + # Attempt to resolve using slug as the unique identifier if one exists + if hasattr(self.model, 'slug'): + try: + return reverse(url_format.format(action), kwargs={'slug': instance.slug}) + except NoReverseMatch: + pass - else: - raise Exception("Invalid action for URL resolution: {}".format(action)) + # Default to using the numeric PK to retrieve the URL for an object + return reverse(url_format.format(action), kwargs={'pk': instance.pk}) class ViewTestCases: @@ -212,12 +216,12 @@ class ViewTestCases: def test_get_object_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self.model.objects.first().get_absolute_url()) + response = self.client.get(self._get_queryset().first().get_absolute_url()) self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): @@ -225,7 +229,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Add model-level permission obj_perm = ObjectPermission( @@ -240,7 +244,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -257,6 +261,16 @@ class ViewTestCases: # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) + class GetObjectChangelogViewTestCase(ModelViewTestCase): + """ + View the changelog for an instance. + """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_get_object_changelog(self): + url = self._get_url('changelog', self._get_queryset().first()) + response = self.client.get(url) + self.assertHttpStatus(response, 200) + class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. @@ -265,12 +279,11 @@ class ViewTestCases: """ form_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object_without_permission(self): # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('add')), 403) + self.assertHttpStatus(self.client.get(self._get_url('add')), 403) # Try POST without permission request = { @@ -281,9 +294,9 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() # Assign unconstrained permission obj_perm = ObjectPermission( @@ -302,12 +315,12 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertEqual(initial_count + 1, self.model.objects.count()) - self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + self.assertEqual(initial_count + 1, self._get_queryset().count()) + self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() # Assign constrained permission obj_perm = ObjectPermission( @@ -327,7 +340,7 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created + self.assertEqual(initial_count, self._get_queryset().count()) # Check that no object was created # Update the ObjectPermission to allow creation obj_perm.constraints = {'pk__gt': 0} @@ -339,8 +352,8 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertEqual(initial_count + 1, self.model.objects.count()) - self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + self.assertEqual(initial_count + 1, self._get_queryset().count()) + self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) class EditObjectViewTestCase(ModelViewTestCase): """ @@ -350,13 +363,12 @@ class ViewTestCases: """ form_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) + self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 403) # Try POST without permission request = { @@ -366,9 +378,9 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Assign model-level permission obj_perm = ObjectPermission( @@ -387,11 +399,11 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Assign constrained permission obj_perm = ObjectPermission( @@ -414,7 +426,7 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self.model.objects.get(pk=instance1.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data) # Try to edit a non-permitted object request = { @@ -427,9 +439,8 @@ class ViewTestCases: """ Delete a single instance. """ - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): @@ -443,9 +454,9 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Assign model-level permission obj_perm = ObjectPermission( @@ -465,11 +476,11 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance.pk) + self._get_queryset().get(pk=instance.pk) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Assign object-level permission obj_perm = ObjectPermission( @@ -493,7 +504,7 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance1.pk) + self._get_queryset().get(pk=instance1.pk) # Try to delete a non-permitted object request = { @@ -501,7 +512,7 @@ class ViewTestCases: 'data': post_data({'confirm': True}), } self.assertHttpStatus(self.client.post(**request), 404) - self.assertTrue(self.model.objects.filter(pk=instance2.pk).exists()) + self.assertTrue(self._get_queryset().filter(pk=instance2.pk).exists()) class ListObjectsViewTestCase(ModelViewTestCase): """ @@ -543,7 +554,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -588,7 +599,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_create_objects_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), @@ -605,13 +616,13 @@ class ViewTestCases: # Bulk create objects response = self.client.post(**request) self.assertHttpStatus(response, 302) - self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) + for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_create_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), @@ -628,7 +639,7 @@ class ViewTestCases: # Attempt to make the request with unmet constraints self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update the ObjectPermission to allow creation obj_perm.constraints = {'pk__gt': 0} # Dummy constraint to allow all @@ -636,8 +647,8 @@ class ViewTestCases: response = self.client.post(**request) self.assertHttpStatus(response, 302) - self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) + for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) class BulkImportObjectsViewTestCase(ModelViewTestCase): @@ -651,7 +662,6 @@ class ViewTestCases: def _get_csv_data(self): return '\n'.join(self.csv_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_import_objects_without_permission(self): data = { 'csv': self._get_csv_data(), @@ -666,9 +676,9 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() data = { 'csv': self._get_csv_data(), } @@ -686,11 +696,11 @@ class ViewTestCases: # Test POST with permission self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() data = { 'csv': self._get_csv_data(), } @@ -706,7 +716,7 @@ class ViewTestCases: # Attempt to import non-permitted objects self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update permission constraints obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all @@ -714,7 +724,7 @@ class ViewTestCases: # Import permitted objects self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) class BulkEditObjectsViewTestCase(ModelViewTestCase): """ @@ -725,9 +735,8 @@ class ViewTestCases: """ bulk_edit_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects_without_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, '_apply': True, # Form button @@ -741,9 +750,9 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, '_apply': True, # Form button @@ -762,13 +771,12 @@ class ViewTestCases: # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_constrained_permission(self): - initial_instances = self.model.objects.all()[:3] - pk_list = list(self.model.objects.values_list('pk', flat=True)[:3]) + pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) data = { 'pk': pk_list, '_apply': True, # Form button @@ -780,7 +788,7 @@ class ViewTestCases: # Dynamically determine a constraint that will *not* be matched by the updated objects. attr_name = list(self.bulk_edit_data.keys())[0] field = self.model._meta.get_field(attr_name) - value = field.value_from_object(self.model.objects.first()) + value = field.value_from_object(self._get_queryset().first()) # Assign constrained permission obj_perm = ObjectPermission( @@ -801,7 +809,7 @@ class ViewTestCases: # Bulk edit permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) class BulkDeleteObjectsViewTestCase(ModelViewTestCase): @@ -810,7 +818,7 @@ class ViewTestCases: """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_without_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, 'confirm': True, @@ -827,7 +835,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True) + pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, 'confirm': True, @@ -844,12 +852,12 @@ class ViewTestCases: # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), 0) + self.assertEqual(self._get_queryset().count(), 0) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() - pk_list = self.model.objects.values_list('pk', flat=True) + initial_count = self._get_queryset().count() + pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, 'confirm': True, @@ -867,7 +875,7 @@ class ViewTestCases: # Attempt to bulk delete non-permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update permission constraints obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all @@ -875,10 +883,11 @@ class ViewTestCases: # Bulk delete permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), 0) + self.assertEqual(self._get_queryset().count(), 0) class PrimaryObjectViewTestCase( GetObjectViewTestCase, + GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, @@ -893,8 +902,10 @@ class ViewTestCases: maxDiff = None class OrganizationalObjectViewTestCase( + GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, + DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkImportObjectsViewTestCase, BulkDeleteObjectsViewTestCase, @@ -917,6 +928,8 @@ class ViewTestCases: maxDiff = None class DeviceComponentViewTestCase( + GetObjectViewTestCase, + GetObjectChangelogViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3f921941e..2cfcb04db 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,4 +1,5 @@ import logging +import re import sys from copy import deepcopy @@ -15,6 +16,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse +from django.urls.exceptions import NoReverseMatch from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.http import is_safe_url @@ -27,7 +29,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction -from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm +from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields from utilities.permissions import get_permission_for_model, resolve_permission from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror @@ -119,7 +121,7 @@ class ObjectPermissionRequiredMixin(AccessMixin): return super().dispatch(request, *args, **kwargs) -class GetReturnURLMixin(object): +class GetReturnURLMixin: """ Provides logic for determining where a user should be redirected after processing a form. """ @@ -134,13 +136,21 @@ class GetReturnURLMixin(object): return query_param # Next, check if the object being modified (if any) has an absolute URL. - elif obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): + if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() # Fall back to the default URL (if specified) for the view. - elif self.default_return_url is not None: + if self.default_return_url is not None: return reverse(self.default_return_url) + # Attempt to dynamically resolve the list view for the object + if hasattr(self, 'queryset'): + model_opts = self.queryset.model._meta + try: + return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list') + except NoReverseMatch: + pass + # If all else fails, return home. Ideally this should never happen. return reverse('home') @@ -160,6 +170,25 @@ class ObjectView(ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_template_name(self): + """ + Return self.template_name if set. Otherwise, resolve the template path by model app_label and name. + """ + if hasattr(self, 'template_name'): + return self.template_name + model_opts = self.queryset.model._meta + return f'{model_opts.app_label}/{model_opts.model_name}.html' + + def get(self, request, pk): + """ + Generic GET handler for accessing an object by PK + """ + instance = get_object_or_404(self.queryset, pk=pk) + + return render(request, self.get_template_name(), { + 'instance': instance, + }) + class ObjectListView(ObjectPermissionRequiredMixin, View): """ @@ -366,6 +395,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} form = self.model_form(instance=obj, initial=initial_data) + restrict_form_fields(form, request.user) return render(request, self.template_name, { 'obj': obj, @@ -382,6 +412,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): files=request.FILES, instance=obj ) + restrict_form_fields(form, request.user) if form.is_valid(): logger.debug("Form validation was successful") @@ -641,6 +672,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Initialize model form data = form.cleaned_data['data'] model_form = self.model_form(data) + restrict_form_fields(model_form, request.user) # Assign default values for any fields which were not specified. We have to do this manually because passing # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not @@ -794,6 +826,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): headers, records = form.cleaned_data['csv'] for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) + restrict_form_fields(obj_form, request.user) if obj_form.is_valid(): obj = self._save_obj(obj_form, request) @@ -875,6 +908,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if '_apply' in request.POST: form = self.form(model, request.POST) + restrict_form_fields(form, request.user) if form.is_valid(): logger.debug("Form validation was successful") @@ -982,6 +1016,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): initial_data['device_type'] = request.GET.get('device_type') form = self.form(model, initial=initial_data) + restrict_form_fields(form, request.user) # Retrieve objects being edited table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) @@ -997,6 +1032,69 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) +class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + An extendable view for renaming objects in bulk. + """ + queryset = None + template_name = 'utilities/obj_bulk_rename.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a new Form class from BulkRenameForm + class _Form(BulkRenameForm): + pk = ModelMultipleChoiceField( + queryset=self.queryset, + widget=MultipleHiddenInput() + ) + + self.form = _Form + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + + def post(self, request): + + if '_preview' in request.POST or '_apply' in request.POST: + form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) + + if form.is_valid(): + for obj in selected_objects: + find = form.cleaned_data['find'] + replace = form.cleaned_data['replace'] + if form.cleaned_data['use_regex']: + try: + obj.new_name = re.sub(find, replace, obj.name) + # Catch regex group reference errors + except re.error: + obj.new_name = obj.name + else: + obj.new_name = obj.name.replace(find, replace) + + if '_apply' in request.POST: + for obj in selected_objects: + obj.name = obj.new_name + obj.save() + messages.success(request, "Renamed {} {}".format( + len(selected_objects), + self.queryset.model._meta.verbose_name_plural + )) + return redirect(self.get_return_url(request)) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) + + return render(request, self.template_name, { + 'form': form, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, + 'selected_objects': selected_objects, + 'return_url': self.get_return_url(request), + }) + + class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete objects in bulk. diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 47b7e6442..de56e6e6a 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -8,7 +8,7 @@ __all__ = [ 'NestedClusterGroupSerializer', 'NestedClusterSerializer', 'NestedClusterTypeSerializer', - 'NestedInterfaceSerializer', + 'NestedVMInterfaceSerializer', 'NestedVirtualMachineSerializer', ] @@ -56,8 +56,8 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name'] -class NestedInterfaceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') +class NestedVMInterfaceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta: diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 008c6dd88..5698791f8 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -11,7 +10,7 @@ from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .nested_serializers import * @@ -95,9 +94,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -108,8 +106,8 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): ) class Meta: - model = Interface + model = VMInterface fields = [ - 'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index c237f1e68..3f6c56a48 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -21,7 +21,7 @@ router.register('clusters', views.ClusterViewSet) # VirtualMachines router.register('virtual-machines', views.VirtualMachineViewSet) -router.register('interfaces', views.InterfaceViewSet) +router.register('interfaces', views.VMInterfaceViewSet) app_name = 'virtualization-api' urlpatterns = router.urls diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 2a1d7c3a9..e3c3224e4 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,11 +1,17 @@ -from django.db.models import Count +from django.db.models import Count, Prefetch +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import action +from rest_framework.response import Response -from dcim.models import Device, Interface +from dcim.models import Device +from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet +from extras.models import Graph +from ipam.models import VLAN from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from . import serializers @@ -71,18 +77,22 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): return serializers.VirtualMachineWithConfigContextSerializer -class InterfaceViewSet(ModelViewSet): - queryset = Interface.objects.filter( +class VMInterfaceViewSet(ModelViewSet): + queryset = VMInterface.objects.filter( virtual_machine__isnull=False ).prefetch_related( + Prefetch('tagged_vlans', queryset=VLAN.objects.unrestricted()), 'virtual_machine', 'tags' ) - serializer_class = serializers.InterfaceSerializer - filterset_class = filters.InterfaceFilterSet + serializer_class = serializers.VMInterfaceSerializer + filterset_class = filters.VMInterfaceFilterSet - def get_serializer_class(self): - request = self.get_serializer_context()['request'] - if request.query_params.get('brief', False): - # Override get_serializer_for_model(), which will return the DCIM NestedInterfaceSerializer - return serializers.NestedInterfaceSerializer - return serializers.InterfaceSerializer + @action(detail=True) + def graphs(self, request, pk): + """ + A convenience method for rendering graphs for a particular VM interface. + """ + vminterface = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='vminterface') + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': vminterface}) + return Response(serializer.data) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 1dae88e1d..3795ddb76 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,4 +1,3 @@ -from dcim.choices import InterfaceTypeChoices from utilities.choices import ChoiceSet @@ -29,16 +28,3 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_ACTIVE: 1, STATUS_STAGED: 3, } - - -# -# Interface types (for VirtualMachines) -# - -class VMInterfaceTypeChoices(ChoiceSet): - - TYPE_VIRTUAL = InterfaceTypeChoices.TYPE_VIRTUAL - - CHOICES = ( - (TYPE_VIRTUAL, 'Virtual'), - ) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 7e8349cf1..33ca44a22 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -9,14 +9,14 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = ( 'ClusterFilterSet', 'ClusterGroupFilterSet', 'ClusterTypeFilterSet', - 'InterfaceFilterSet', 'VirtualMachineFilterSet', + 'VMInterfaceFilterSet', ) @@ -201,7 +201,7 @@ class VirtualMachineFilterSet( ) -class InterfaceFilterSet(BaseFilterSet): +class VMInterfaceFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -222,7 +222,7 @@ class InterfaceFilterSet(BaseFilterSet): ) class Meta: - model = Interface + model = VMInterface fields = ['id', 'name', 'enabled', 'mtu'] def search(self, queryset, name, value): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 942368f19..ce6eea1e8 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -14,12 +14,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, - StaticSelect2, StaticSelect2Multiple, TagFilterField, + BulkRenameForm, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, + SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface # @@ -356,7 +356,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ip_choices = [(None, '---------')] # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface__virtual_machine=self.instance + address__family=family, + vminterface__in=self.instance.interfaces.values_list('id', flat=True) ) if interface_ips: ip_choices.append( @@ -366,7 +367,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, nat_inside__interface__virtual_machine=self.instance + address__family=family, + nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True) ) if nat_ips: ip_choices.append( @@ -569,7 +571,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # VM interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm): +class VMInterfaceForm(BootstrapMixin, forms.ModelForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -598,14 +600,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): ) class Meta: - model = Interface + model = VMInterface fields = [ - 'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', + 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), - 'type': forms.HiddenInput(), 'mode': StaticSelect2() } labels = { @@ -618,10 +619,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + virtual_machine = VirtualMachine.objects.get( + pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') + ) + # Add current site to VLANs query params - site = getattr(self.instance.parent, 'site', None) - if site is not None: - # Add current site to VLANs query params + site = virtual_machine.site + if site: self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) @@ -642,19 +646,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): self.cleaned_data['tagged_vlans'] = [] -class InterfaceCreateForm(BootstrapMixin, forms.Form): - virtual_machine = forms.ModelChoiceField( - queryset=VirtualMachine.objects.all(), - widget=forms.HiddenInput() +class VMInterfaceCreateForm(BootstrapMixin, forms.Form): + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() ) name_pattern = ExpandableNameField( label='Name' ) - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) enabled = forms.BooleanField( required=False, initial=True @@ -712,16 +710,39 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine') ) - site = getattr(virtual_machine.cluster, 'site', None) - if site is not None: - # Add current site to VLANs query params + # Add current site to VLANs query params + site = virtual_machine.site + if site: self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): +class VMInterfaceCSVForm(CSVModelForm): + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + to_field_name='name' + ) + mode = CSVChoiceField( + choices=InterfaceModeChoices, + required=False, + help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + ) + + class Meta: + model = VMInterface + fields = VMInterface.csv_headers + + def clean_enabled(self): + # Make sure enabled is True when it's not included in the uploaded data + if 'enabled' not in self.data: + return True + else: + return self.cleaned_data['enabled'] + + +class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), + queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() ) virtual_machine = forms.ModelChoiceField( @@ -789,6 +810,24 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) +class VMInterfaceBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=VMInterface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + +class VMInterfaceFilterForm(forms.Form): + model = VMInterface + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + # # Bulk VirtualMachine component creation # @@ -808,12 +847,8 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): return ','.join(self.cleaned_data.get('tags')) -class InterfaceBulkCreateForm( - form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), +class VMInterfaceBulkCreateForm( + form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) + pass diff --git a/netbox/virtualization/migrations/0015_vminterface.py b/netbox/virtualization/migrations/0015_vminterface.py new file mode 100644 index 000000000..6c5207226 --- /dev/null +++ b/netbox/virtualization/migrations/0015_vminterface.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.6 on 2020-06-18 20:21 + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering +import utilities.query_functions + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0036_standardize_description'), + ('extras', '0042_customfield_manager'), + ('virtualization', '0014_standardize_description'), + ] + + operations = [ + migrations.CreateModel( + name='VMInterface', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)), + ('enabled', models.BooleanField(default=True)), + ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), + ('mtu', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)])), + ('mode', models.CharField(blank=True, max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('tagged_vlans', models.ManyToManyField(blank=True, related_name='vminterfaces_as_tagged', to='ipam.VLAN')), + ('tags', taggit.managers.TaggableManager(related_name='vminterface', through='extras.TaggedItem', to='extras.Tag')), + ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces_as_untagged', to='ipam.VLAN')), + ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')), + ], + options={ + 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')), + 'unique_together': {('virtual_machine', 'name')}, + 'verbose_name': 'interface', + }, + ), + ] diff --git a/netbox/virtualization/migrations/0016_replicate_interfaces.py b/netbox/virtualization/migrations/0016_replicate_interfaces.py new file mode 100644 index 000000000..d6c0b0217 --- /dev/null +++ b/netbox/virtualization/migrations/0016_replicate_interfaces.py @@ -0,0 +1,69 @@ +import sys + +from django.db import migrations + + +def replicate_interfaces(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + TaggedItem = apps.get_model('extras', 'TaggedItem') + Interface = apps.get_model('dcim', 'Interface') + IPAddress = apps.get_model('ipam', 'IPAddress') + VMInterface = apps.get_model('virtualization', 'VMInterface') + + interface_ct = ContentType.objects.get_for_model(Interface) + vminterface_ct = ContentType.objects.get_for_model(VMInterface) + + # Replicate dcim.Interface instances assigned to VirtualMachines + original_interfaces = Interface.objects.filter(virtual_machine__isnull=False) + for interface in original_interfaces: + vminterface = VMInterface( + virtual_machine=interface.virtual_machine, + name=interface.name, + enabled=interface.enabled, + mac_address=interface.mac_address, + mtu=interface.mtu, + mode=interface.mode, + description=interface.description, + untagged_vlan=interface.untagged_vlan, + ) + vminterface.save() + + # Copy tagged VLANs + vminterface.tagged_vlans.set(interface.tagged_vlans.all()) + + # Reassign tags to the new instance + TaggedItem.objects.filter( + content_type=interface_ct, object_id=interface.pk + ).update( + content_type=vminterface_ct, object_id=vminterface.pk + ) + + # Update any assigned IPAddresses + IPAddress.objects.filter(assigned_object_id=interface.pk).update( + assigned_object_type=vminterface_ct, + assigned_object_id=vminterface.pk + ) + + replicated_count = VMInterface.objects.count() + if 'test' not in sys.argv: + print(f"\n Replicated {replicated_count} interfaces ", end='', flush=True) + + # Verify that all interfaces have been replicated + assert replicated_count == original_interfaces.count(), "Replicated interfaces count does not match original count!" + + # Delete original VM interfaces + original_interfaces.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0037_ipaddress_assignment'), + ('virtualization', '0015_vminterface'), + ] + + operations = [ + migrations.RunPython( + code=replicate_interfaces + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8ad40bab7..5d74f8468 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -5,11 +5,14 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Device -from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from dcim.choices import InterfaceModeChoices +from dcim.models import BaseInterface, Device +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.query_functions import CollateAsChar from utilities.querysets import RestrictedQuerySet +from utilities.utils import serialize_object from .choices import * @@ -18,6 +21,7 @@ __all__ = ( 'ClusterGroup', 'ClusterType', 'VirtualMachine', + 'VMInterface', ) @@ -172,7 +176,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. if self.pk and self.site: - nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + nonsite_devices = Device.objects.unrestricted().filter(cluster=self).exclude(site=self.site).count() if nonsite_devices: raise ValidationError({ 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( @@ -312,7 +316,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. - if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( + if self.tenant is None and VirtualMachine.objects.unrestricted().exclude(pk=self.pk).filter( name=self.name, tenant__isnull=True ): raise ValidationError({ @@ -370,3 +374,111 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): @property def site(self): return self.cluster.site + + +# +# Interfaces +# + +@extras_features('graphs', 'export_templates', 'webhooks') +class VMInterface(BaseInterface): + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' + ) + description = models.CharField( + max_length=200, + blank=True + ) + untagged_vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='vminterfaces_as_untagged', + null=True, + blank=True, + verbose_name='Untagged VLAN' + ) + tagged_vlans = models.ManyToManyField( + to='ipam.VLAN', + related_name='vminterfaces_as_tagged', + blank=True, + verbose_name='Tagged VLANs' + ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vminterface' + ) + tags = TaggableManager( + through=TaggedItem, + related_name='vminterface' + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = [ + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + ] + + class Meta: + verbose_name = 'interface' + ordering = ('virtual_machine', CollateAsChar('_name')) + unique_together = ('virtual_machine', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) + + def to_csv(self): + return ( + self.virtual_machine.name, + self.name, + self.enabled, + self.mac_address, + self.mtu, + self.description, + self.get_mode_display(), + ) + + def clean(self): + + # Validate untagged VLAN + if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: + raise ValidationError({ + 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " + "virtual machine, or it must be global".format(self.untagged_vlan) + }) + + def save(self, *args, **kwargs): + + # Remove untagged VLAN assignment for non-802.1Q interfaces + if self.mode is None: + self.untagged_vlan = None + + # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: + self.tagged_vlans.clear() + + return super().save(*args, **kwargs) + + def to_objectchange(self, action): + # Annotate the parent VirtualMachine + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + related_object=self.virtual_machine, + object_data=serialize_object(self) + ) + + @property + def parent(self): + return self.virtual_machine + + @property + def count_ipaddresses(self): + return self.ip_addresses.count() diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index d957e0053..d53572583 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,28 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine - -CLUSTERTYPE_ACTIONS = """ - - - -{% if perms.virtualization.change_clustertype %} - -{% endif %} -""" - -CLUSTERGROUP_ACTIONS = """ - - - -{% if perms.virtualization.change_clustergroup %} - -{% endif %} -""" +from utilities.tables import BaseTable, ButtonsColumn, ColoredLabelColumn, TagColumn, ToggleColumn +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface VIRTUALMACHINE_STATUS = """ {{ record.get_status_display }} @@ -45,11 +26,7 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = tables.TemplateColumn( - template_code=CLUSTERTYPE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(ClusterType, pk_field='slug') class Meta(BaseTable.Meta): model = ClusterType @@ -67,11 +44,7 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = tables.TemplateColumn( - template_code=CLUSTERGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(ClusterGroup, pk_field='slug') class Meta(BaseTable.Meta): model = ClusterGroup @@ -173,8 +146,12 @@ class VirtualMachineDetailTable(VirtualMachineTable): # VM components # -class InterfaceTable(BaseTable): +class VMInterfaceTable(BaseTable): + virtual_machine = tables.LinkColumn() + name = tables.Column( + linkify=True + ) class Meta(BaseTable.Meta): - model = Interface - fields = ('name', 'enabled', 'description') + model = VMInterface + fields = ('virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 6b466116e..e108100a9 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,12 +1,12 @@ +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from dcim.models import Interface +from extras.models import Graph from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases -from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class AppTest(APITestCase): @@ -163,7 +163,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): """ Check that config context data is included by default in the virtual machines list. """ - virtualmachine = VirtualMachine.objects.first() + virtualmachine = VirtualMachine.objects.unrestricted().first() url = '{}?id={}'.format(reverse('virtualization-api:virtualmachine-list'), virtualmachine.pk) self.add_permissions('virtualization.view_virtualmachine') @@ -186,7 +186,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): """ data = { 'name': 'Virtual Machine 1', - 'cluster': Cluster.objects.first().pk, + 'cluster': Cluster.objects.unrestricted().first().pk, } url = reverse('virtualization-api:virtualmachine-list') self.add_permissions('virtualization.add_virtualmachine') @@ -195,172 +195,72 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -# TODO: Standardize InterfaceTest (pending #4721) -class InterfaceTest(APITestCase): +class VMInterfaceTest(APIViewTestCases.APIViewTestCase): + model = VMInterface + brief_fields = ['id', 'name', 'url', 'virtual_machine'] - def setUp(self): - - super().setUp() + @classmethod + def setUpTestData(cls): clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) - self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') - self.interface1 = Interface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 1', - type=InterfaceTypeChoices.TYPE_VIRTUAL + virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') + + interfaces = ( + VMInterface(virtual_machine=virtualmachine, name='Interface 1'), + VMInterface(virtual_machine=virtualmachine, name='Interface 2'), + VMInterface(virtual_machine=virtualmachine, name='Interface 3'), ) - self.interface2 = Interface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 2', - type=InterfaceTypeChoices.TYPE_VIRTUAL - ) - self.interface3 = Interface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 3', - type=InterfaceTypeChoices.TYPE_VIRTUAL + VMInterface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), ) + VLAN.objects.bulk_create(vlans) - self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) - self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2) - self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) - - def test_get_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.view_interface') - - response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.interface1.name) - - def test_list_interfaces(self): - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') - - response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) - - def test_list_interfaces_brief(self): - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') - - response = self.client.get('{}?brief=1'.format(url), **self.header) - self.assertEqual( - sorted(response.data['results'][0]), - ['id', 'name', 'url', 'virtual_machine'] - ) - - def test_create_interface(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - } - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 4) - interface4 = Interface.objects.get(pk=response.data['id']) - self.assertEqual(interface4.virtual_machine_id, data['virtual_machine']) - self.assertEqual(interface4.name, data['name']) - - def test_create_interface_with_802_1q(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan3.id, - 'tagged_vlans': [self.vlan1.id, self.vlan2.id], - } - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 4) - self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) - self.assertEqual(response.data['name'], data['name']) - self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) - self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) - - def test_create_interface_bulk(self): - data = [ + cls.create_data = [ { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 4', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 5', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 5', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 6', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 6', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, ] - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) + def test_get_interface_graphs(self): + """ + Test retrieval of Graphs assigned to VM interfaces. + """ + ct = ContentType.objects.get_for_model(VMInterface) + graphs = ( + Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'), + Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'), + Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'), + ) + Graph.objects.bulk_create(graphs) - def test_create_interface_802_1q_bulk(self): - data = [ - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 5', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 6', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - ] - url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.view_vminterface') + url = reverse('virtualization-api:vminterface-graphs', kwargs={ + 'pk': VMInterface.objects.unrestricted().first().pk + }) + response = self.client.get(url, **self.header) - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 6) - for i in range(0, 3): - self.assertEqual(response.data[i]['name'], data[i]['name']) - self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) - self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) - - def test_update_interface(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface X', - } - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.change_interface') - - response = self.client.put(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Interface.objects.count(), 3) - interface1 = Interface.objects.get(pk=response.data['id']) - self.assertEqual(interface1.name, data['name']) - - def test_delete_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.delete_interface') - - response = self.client.delete(url, **self.header) - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Interface.objects.count(), 2) + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index 51c7c6e8d..ad452ec51 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -1,10 +1,10 @@ from django.test import TestCase -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from virtualization.choices import * from virtualization.filters import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class ClusterTypeTestCase(TestCase): @@ -260,11 +260,11 @@ class VirtualMachineTestCase(TestCase): VirtualMachine.objects.bulk_create(vms) interfaces = ( - Interface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'), - Interface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'), - Interface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'), + VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'), + VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(interfaces) def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} @@ -365,9 +365,9 @@ class VirtualMachineTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InterfaceTestCase(TestCase): - queryset = Interface.objects.all() - filterset = InterfaceFilterSet +class VMInterfaceTestCase(TestCase): + queryset = VMInterface.objects.all() + filterset = VMInterfaceFilterSet @classmethod def setUpTestData(cls): @@ -394,11 +394,11 @@ class InterfaceTestCase(TestCase): VirtualMachine.objects.bulk_create(vms) interfaces = ( - Interface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), - Interface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), - Interface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), + VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), + VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(interfaces) def test_id(self): id_list = self.queryset.values_list('id', flat=True)[:2] diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index a98496f29..ec4159dd4 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,11 +1,11 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Interface, Platform, Site +from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN from utilities.testing import ViewTestCases from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -189,21 +189,8 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -# TODO: Update base class to DeviceComponentViewTestCase -# Blocked by #4721 -class InterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.BulkCreateObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase, -): - model = Interface - - def _get_base_url(self): - # Interface belongs to the DCIM app, so we have to override the base URL - return 'virtualization:interface_{}' +class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = VMInterface @classmethod def setUpTestData(cls): @@ -218,10 +205,10 @@ class InterfaceTestCase( ) VirtualMachine.objects.bulk_create(virtualmachines) - Interface.objects.bulk_create([ - Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + VMInterface.objects.bulk_create([ + VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), + VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), + VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), ]) vlans = ( @@ -237,9 +224,7 @@ class InterfaceTestCase( cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, - 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description', @@ -252,9 +237,7 @@ class InterfaceTestCase( cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, - 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description', @@ -264,19 +247,19 @@ class InterfaceTestCase( 'tags': [t.pk for t in tags], } + cls.csv_data = ( + "virtual_machine,name", + "Virtual Machine 2,Interface 4", + "Virtual Machine 2,Interface 5", + "Virtual Machine 2,Interface 6", + ) + cls.bulk_edit_data = { 'virtual_machine': virtualmachines[1].pk, 'enabled': False, 'mtu': 2000, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, - # 'untagged_vlan': vlans[0].pk, - # 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], } - - cls.csv_data = ( - "device,name,type", - "Device 1,Interface 4,1000BASE-T (1GE)", - "Device 1,Interface 5,1000BASE-T (1GE)", - "Device 1,Interface 6,1000BASE-T (1GE)", - ) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 38ad1a8b1..3d6f07566 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -3,7 +3,7 @@ from django.urls import path from extras.views import ObjectChangeLogView from ipam.views import ServiceEditView from . import views -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface app_name = 'virtualization' urlpatterns = [ @@ -14,6 +14,7 @@ urlpatterns = [ path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups @@ -22,6 +23,7 @@ urlpatterns = [ path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters @@ -51,11 +53,16 @@ urlpatterns = [ path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces - path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), - path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), + path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'), + path('interfaces/add/', views.VMInterfaceCreateView.as_view(), name='vminterface_add'), + path('interfaces/import/', views.VMInterfaceBulkImportView.as_view(), name='vminterface_import'), + path('interfaces/edit/', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'), + path('interfaces/rename/', views.VMInterfaceBulkRenameView.as_view(), name='vminterface_bulk_rename'), + path('interfaces/delete/', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'), + path('interfaces//', views.VMInterfaceView.as_view(), name='vminterface'), + path('interfaces//edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), + path('interfaces//delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), + path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}), + path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index aea4d0556..176c89f2e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,19 +1,20 @@ from django.contrib import messages from django.db import transaction -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from dcim.models import Device, Interface +from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView -from ipam.models import Service +from ipam.models import IPAddress, Service +from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView, - ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, + ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface # @@ -28,20 +29,21 @@ class ClusterTypeListView(ObjectListView): class ClusterTypeEditView(ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm - default_return_url = 'virtualization:clustertype_list' + + +class ClusterTypeDeleteView(ObjectDeleteView): + queryset = ClusterType.objects.all() class ClusterTypeBulkImportView(BulkImportView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm table = tables.ClusterTypeTable - default_return_url = 'virtualization:clustertype_list' class ClusterTypeBulkDeleteView(BulkDeleteView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable - default_return_url = 'virtualization:clustertype_list' # @@ -56,20 +58,21 @@ class ClusterGroupListView(ObjectListView): class ClusterGroupEditView(ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm - default_return_url = 'virtualization:clustergroup_list' + + +class ClusterGroupDeleteView(ObjectDeleteView): + queryset = ClusterGroup.objects.all() class ClusterGroupBulkImportView(BulkImportView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable - default_return_url = 'virtualization:clustergroup_list' class ClusterGroupBulkDeleteView(BulkDeleteView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable - default_return_url = 'virtualization:clustergroup_list' # @@ -87,6 +90,9 @@ class ClusterView(ObjectView): queryset = Cluster.objects.all() def get(self, request, pk): + self.queryset = self.queryset.prefetch_related( + Prefetch('virtual_machines', queryset=VirtualMachine.objects.restrict(request.user)) + ) cluster = get_object_or_404(self.queryset, pk=pk) devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related( @@ -110,14 +116,12 @@ class ClusterEditView(ObjectEditView): class ClusterDeleteView(ObjectDeleteView): queryset = Cluster.objects.all() - default_return_url = 'virtualization:cluster_list' class ClusterBulkImportView(BulkImportView): queryset = Cluster.objects.all() model_form = forms.ClusterCSVForm table = tables.ClusterTable - default_return_url = 'virtualization:cluster_list' class ClusterBulkEditView(BulkEditView): @@ -125,14 +129,12 @@ class ClusterBulkEditView(BulkEditView): filterset = filters.ClusterFilterSet table = tables.ClusterTable form = forms.ClusterBulkEditForm - default_return_url = 'virtualization:cluster_list' class ClusterBulkDeleteView(BulkDeleteView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable - default_return_url = 'virtualization:cluster_list' class ClusterAddDevicesView(ObjectEditView): @@ -235,8 +237,16 @@ class VirtualMachineView(ObjectView): def get(self, request, pk): virtualmachine = get_object_or_404(self.queryset, pk=pk) - interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) - services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + interfaces = VMInterface.objects.restrict(request.user, 'view').filter( + virtual_machine=virtualmachine + ).prefetch_related( + Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)) + ) + services = Service.objects.restrict(request.user, 'view').filter( + virtual_machine=virtualmachine + ).prefetch_related( + Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)) + ) return render(request, 'virtualization/virtualmachine.html', { 'virtualmachine': virtualmachine, @@ -254,19 +264,16 @@ class VirtualMachineEditView(ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineDeleteView(ObjectDeleteView): queryset = VirtualMachine.objects.all() - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkImportView(BulkImportView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineCSVForm table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkEditView(BulkEditView): @@ -274,46 +281,98 @@ class VirtualMachineBulkEditView(BulkEditView): filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkDeleteView(BulkDeleteView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list' # # VM interfaces # -class InterfaceCreateView(ComponentCreateView): - queryset = Interface.objects.all() - form = forms.InterfaceCreateForm - model_form = forms.InterfaceForm +class VMInterfaceListView(ObjectListView): + queryset = VMInterface.objects.prefetch_related('virtual_machine') + filterset = filters.VMInterfaceFilterSet + filterset_form = forms.VMInterfaceFilterForm + table = tables.VMInterfaceTable + action_buttons = ('export',) + + +class VMInterfaceView(ObjectView): + queryset = VMInterface.objects.all() + + def get(self, request, pk): + + vminterface = get_object_or_404(self.queryset, pk=pk) + + # Get assigned IP addresses + ipaddress_table = InterfaceIPAddressTable( + data=vminterface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + orderable=False + ) + + # Get assigned VLANs and annotate whether each is tagged or untagged + vlans = [] + if vminterface.untagged_vlan is not None: + vlans.append(vminterface.untagged_vlan) + vlans[0].tagged = False + for vlan in vminterface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'): + vlan.tagged = True + vlans.append(vlan) + vlan_table = InterfaceVLANTable( + interface=vminterface, + data=vlans, + orderable=False + ) + + return render(request, 'virtualization/vminterface.html', { + 'vminterface': vminterface, + 'ipaddress_table': ipaddress_table, + 'vlan_table': vlan_table, + }) + + +# TODO: This should not use ComponentCreateView +class VMInterfaceCreateView(ComponentCreateView): + queryset = VMInterface.objects.all() + form = forms.VMInterfaceCreateForm + model_form = forms.VMInterfaceForm template_name = 'virtualization/virtualmachine_component_add.html' -class InterfaceEditView(ObjectEditView): - queryset = Interface.objects.all() - model_form = forms.InterfaceForm - template_name = 'virtualization/interface_edit.html' +class VMInterfaceEditView(ObjectEditView): + queryset = VMInterface.objects.all() + model_form = forms.VMInterfaceForm + template_name = 'virtualization/vminterface_edit.html' -class InterfaceDeleteView(ObjectDeleteView): - queryset = Interface.objects.all() +class VMInterfaceDeleteView(ObjectDeleteView): + queryset = VMInterface.objects.all() -class InterfaceBulkEditView(BulkEditView): - queryset = Interface.objects.all() - table = tables.InterfaceTable - form = forms.InterfaceBulkEditForm +class VMInterfaceBulkImportView(BulkImportView): + queryset = VMInterface.objects.all() + model_form = forms.VMInterfaceCSVForm + table = tables.VMInterfaceTable -class InterfaceBulkDeleteView(BulkDeleteView): - queryset = Interface.objects.all() - table = tables.InterfaceTable +class VMInterfaceBulkEditView(BulkEditView): + queryset = VMInterface.objects.all() + table = tables.VMInterfaceTable + form = forms.VMInterfaceBulkEditForm + + +class VMInterfaceBulkRenameView(BulkRenameView): + queryset = VMInterface.objects.all() + form = forms.VMInterfaceBulkRenameForm + + +class VMInterfaceBulkDeleteView(BulkDeleteView): + queryset = VMInterface.objects.all() + table = tables.VMInterfaceTable # @@ -323,9 +382,8 @@ class InterfaceBulkDeleteView(BulkDeleteView): class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView): parent_model = VirtualMachine parent_field = 'virtual_machine' - form = forms.InterfaceBulkCreateForm - queryset = Interface.objects.all() - model_form = forms.InterfaceForm + form = forms.VMInterfaceBulkCreateForm + queryset = VMInterface.objects.all() + model_form = forms.VMInterfaceForm filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list'