diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 1c4ec7838..c1026dec7 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -496,7 +496,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): queryset = PowerPort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilter diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0783b8648..c52806fe3 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -420,7 +420,7 @@ CABLE_TERMINATION_TYPE_CHOICES = { COMPATIBLE_TERMINATION_TYPES = { 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet'], + 'powerport': ['poweroutlet', 'powerfeed'], 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index fd37f2839..6abde2fc6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -10,6 +10,7 @@ from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField from timezone_field import TimeZoneFormField +from circuits.models import Circuit, CircuitTermination, Provider from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm @@ -2521,7 +2522,7 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', @@ -2602,6 +2603,104 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): ) +class ConnectCableToCircuitForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + widget=APISelect( + api_url='/api/circuits/providers/', + filter_for={ + 'termination_b_circuit': 'provider_id', + } + ) + ) + termination_b_circuit = ChainedModelChoiceField( + queryset=Circuit.objects.all(), + chains=( + ('provider', 'termination_b_provider'), + ), + label='Circuit', + widget=APISelect( + api_url='/api/circuits/circuits/', + display_field='cid', + filter_for={ + 'termination_b_id': 'circuit_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Termination', + widget=APISelect( + api_url='/api/circuits/circuit-terminations/', + disabled_indicator='cable' + ) + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_provider', 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'label', 'color', + 'length', 'length_unit', + ] + + +class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + widget=APISelect( + api_url='/api/dcim/sites/', + display_field='cid', + filter_for={ + 'termination_b_rackgroup': 'site_id', + 'termination_b_powerpanel': 'site_id', + } + ) + ) + termination_b_rackgroup = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + label='Rack Group', + chains=( + ('site', 'termination_b_site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/', + display_field='cid', + filter_for={ + 'termination_b_powerpanel': 'rackgroup_id', + } + ) + ) + termination_b_powerpanel = ChainedModelChoiceField( + queryset=PowerPanel.objects.all(), + chains=( + ('site', 'termination_b_site'), + ('rack_group', 'termination_b_rackgroup'), + ), + label='Power Panel', + widget=APISelect( + api_url='/api/dcim/power-panels/', + filter_for={ + 'termination_b_powerfeed': 'powerpanel_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Power Feed', + widget=APISelect( + api_url='/api/dcim/power-feeds/', + ) + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] + + class CableForm(BootstrapMixin, forms.ModelForm): class Meta: diff --git a/netbox/dcim/migrations/0072_powerfeeds.py b/netbox/dcim/migrations/0072_powerfeeds.py index 57b86ff40..dc99a89ac 100644 --- a/netbox/dcim/migrations/0072_powerfeeds.py +++ b/netbox/dcim/migrations/0072_powerfeeds.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.7 on 2019-03-12 14:08 +# Generated by Django 2.1.7 on 2019-03-21 20:59 import django.core.validators from django.db import migrations, models @@ -21,14 +21,15 @@ class Migration(migrations.Migration): ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('name', models.CharField(max_length=50)), - ('type', models.PositiveSmallIntegerField(default=1)), ('status', models.PositiveSmallIntegerField(default=1)), + ('type', models.PositiveSmallIntegerField(default=1)), ('supply', models.PositiveSmallIntegerField(default=1)), + ('phase', models.PositiveSmallIntegerField(default=1)), ('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])), ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), - ('phase', models.PositiveSmallIntegerField(default=1)), ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), ('comments', models.TextField(blank=True)), + ('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')), ], options={ 'ordering': ['power_panel', 'name'], @@ -63,6 +64,26 @@ class Migration(migrations.Migration): name='tags', field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), ), + migrations.AddField( + model_name='powerfeed', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'), + ), + migrations.AddField( + model_name='powerfeed', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.RenameField( + model_name='powerport', + old_name='connected_endpoint', + new_name='_connected_poweroutlet', + ), + migrations.AddField( + model_name='powerport', + name='_connected_powerfeed', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'), + ), migrations.AlterUniqueTogether( name='powerpanel', unique_together={('site', 'name')}, diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 021d4e1a1..0d9750dfd 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1828,13 +1828,20 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) - connected_endpoint = models.OneToOneField( + _connected_poweroutlet = models.OneToOneField( to='dcim.PowerOutlet', on_delete=models.SET_NULL, related_name='connected_endpoint', blank=True, null=True ) + _connected_powerfeed = models.OneToOneField( + to='dcim.PowerFeed', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True @@ -1862,6 +1869,28 @@ class PowerPort(CableTermination, ComponentModel): self.description, ) + @property + def connected_endpoint(self): + if self._connected_poweroutlet: + return self._connected_poweroutlet + return self._connected_powerfeed + + @connected_endpoint.setter + def connected_endpoint(self, value): + if value is None: + self._connected_poweroutlet = None + self._connected_powerfeed = None + elif isinstance(value, PowerOutlet): + self._connected_poweroutlet = value + self._connected_powerfeed = None + elif isinstance(value, PowerFeed): + self._connected_poweroutlet = None + self._connected_powerfeed = value + else: + raise ValueError( + "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value)) + ) + # # Power outlets @@ -2646,6 +2675,14 @@ class Cable(ChangeLoggedModel): def get_status_class(self): return 'success' if self.status else 'info' + def get_compatible_types(self): + """ + Return all termination types compatible with termination A. + """ + if self.termination_a is None: + return + return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + def get_path_endpoints(self): """ Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be @@ -2712,7 +2749,7 @@ class PowerPanel(ChangeLoggedModel): ) -class PowerFeed(ChangeLoggedModel, CustomFieldModel): +class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -2727,6 +2764,17 @@ class PowerFeed(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) + connected_endpoint = models.OneToOneField( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) name = models.CharField( max_length=50 ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a8629245f..853e6ed57 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,6 +3,7 @@ import re from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +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 @@ -10,10 +11,11 @@ from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape +from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View -from circuits.models import Circuit +from circuits.models import Circuit, CircuitTermination from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.views import ObjectConfigContextView from ipam.models import Prefix, VLAN @@ -913,7 +915,7 @@ class DeviceView(View): consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') + power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') # Power outlets poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') @@ -1673,20 +1675,76 @@ class CableTraceView(View): }) -class CableCreateView(PermissionRequiredMixin, ObjectEditView): +class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): permission_required = 'dcim.add_cable' - model = Cable - model_form = forms.CableCreateForm template_name = 'dcim/cable_connect.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def _get_form_class(self): + if self.termination_b_type == 'circuit': + return forms.ConnectCableToCircuitForm + if self.termination_b_type == 'powerfeed': + return forms.ConnectCableToPowerFeedForm + return forms.ConnectCableToDeviceForm + + def dispatch(self, request, *args, **kwargs): # Retrieve endpoint A based on the given type and PK - termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + termination_a_type = kwargs.get('termination_a_type') + termination_a_id = kwargs.get('termination_a_id') + self.obj = Cable( + termination_a=termination_a_type.objects.get(pk=termination_a_id) + ) - return obj + self.termination_b_type = request.GET.get('type') + if self.termination_b_type == 'circuit': + self.obj.termination_b_type = ContentType.objects.get_for_model(CircuitTermination) + elif self.termination_b_type == 'powerfeed': + self.obj.termination_b_type = ContentType.objects.get_for_model(PowerFeed) + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + + # Parse initial data manually to avoid setting field values as lists + initial_data = {k: request.GET[k] for k in request.GET} + + form = self._get_form_class()(instance=self.obj, initial=initial_data) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) + + def post(self, request, *args, **kwargs): + + form = self._get_form_class()(request.POST, request.FILES, instance=self.obj) + + if form.is_valid(): + obj = form.save() + + msg = 'Created cable {}'.format( + obj.get_absolute_url(), + escape(obj) + ) + messages.success(request, mark_safe(msg)) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, obj)) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) class CableEditView(PermissionRequiredMixin, ObjectEditView): @@ -1763,11 +1821,11 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device' + 'device', '_connected_poweroutlet__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ).order_by( - 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + 'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name' ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ccdcb51ab..7815a2fbf 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -541,7 +541,7 @@ class TopologyMap(models.Model): from dcim.models import PowerPort # Add all power connections to the graph - for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): + for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices): style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 837d9473d..a0e8d6a7e 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -166,7 +166,7 @@ class HomeView(View): connected_endpoint__isnull=False ) connected_powerports = PowerPort.objects.filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) connected_interfaces = Interface.objects.filter( _connected_interface__isnull=False, diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index cad396966..52c1444d4 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -101,21 +101,34 @@ B Side