Merge pull request #1181 from digitalocean/develop

Release v2.0.2
This commit is contained in:
Jeremy Stretch 2017-05-15 13:23:33 -04:00 committed by GitHub
commit 43e1e0dbc8
34 changed files with 624 additions and 485 deletions

View File

@ -25,7 +25,7 @@ server {
server_name netbox.example.com; server_name netbox.example.com;
access_log off; client_max_body_size 25m;
location /static/ { location /static/ {
alias /opt/netbox/netbox/static/; alias /opt/netbox/netbox/static/;

View File

@ -3,10 +3,11 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
SlugField, FilterChoiceField, Livesearch, SmallTextarea, SlugField,
) )
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -83,12 +84,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
# Circuits # Circuits
# #
class CircuitForm(BootstrapMixin, CustomFieldForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
comments = CommentField() comments = CommentField()
class Meta: class Meta:
model = Circuit model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] fields = [
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments',
]
help_texts = { help_texts = {
'cid': "Unique circuit ID", 'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD", 'install_date': "Format: YYYY-MM-DD",
@ -152,15 +156,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Circuit terminations # Circuit terminations
# #
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'rack'} attrs={'filter-for': 'rack'}
) )
) )
rack = forms.ModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'},
required=False, required=False,
label='Rack', label='Rack',
widget=APISelect( widget=APISelect(
@ -168,8 +173,9 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device', 'nullable': 'true'} attrs={'filter-for': 'device', 'nullable': 'true'}
) )
) )
device = forms.ModelChoiceField( device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
required=False, required=False,
label='Device', label='Device',
widget=APISelect( widget=APISelect(
@ -187,8 +193,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
field_to_update='device' field_to_update='device'
) )
) )
interface = forms.ModelChoiceField( interface = ChainedModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
),
chains={'device': 'device'},
required=False, required=False,
label='Interface', label='Interface',
widget=APISelect( widget=APISelect(
@ -212,49 +221,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
if instance and instance.interface is not None:
initial = kwargs.get('initial', {})
initial['rack'] = instance.interface.device.rack
initial['device'] = instance.interface.device
kwargs['initial'] = initial
super(CircuitTerminationForm, self).__init__(*args, **kwargs) super(CircuitTerminationForm, self).__init__(*args, **kwargs)
# If an interface has been assigned, initialize rack and device # Mark connected interfaces as disabled
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
self.fields['interface'].choices = [ self.fields['interface'].choices = [
(iface.id, { (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset
'label': iface.name,
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
] ]

View File

@ -79,7 +79,13 @@ class CircuitSearchTable(SearchTable):
cid = tables.LinkColumn(verbose_name='ID') cid = tables.LinkColumn(verbose_name='ID')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
a_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')]
)
z_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')]
)
class Meta(SearchTable.Meta): class Meta(SearchTable.Meta):
model = Circuit model = Circuit
fields = ('cid', 'type', 'provider', 'tenant', 'description') fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

@ -608,6 +608,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
class PeerInterfaceSerializer(serializers.ModelSerializer): class PeerInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
class Meta: class Meta:
model = Interface model = Interface

View File

@ -8,11 +8,13 @@ from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
) )
from .formfields import MACAddressFormField from .formfields import MACAddressFormField
@ -80,7 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
# Sites # Sites
# #
class SiteForm(BootstrapMixin, CustomFieldForm): class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
@ -88,8 +90,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -184,16 +186,23 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
# Racks # Racks
# #
class RackForm(BootstrapMixin, CustomFieldForm): class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( group = ChainedModelChoiceField(
api_url='/api/dcim/rack-groups/?site_id={{site}}', queryset=RackGroup.objects.all(),
)) chains={'site': 'site'},
required=False,
widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
)
)
comments = CommentField() comments = CommentField()
class Meta: class Meta:
model = Rack model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', fields = [
'comments'] 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments',
]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
'name': "Organizational rack name", 'name': "Organizational rack name",
@ -204,18 +213,6 @@ class RackForm(BootstrapMixin, CustomFieldForm):
'site': forms.Select(attrs={'filter-for': 'group'}), 'site': forms.Select(attrs={'filter-for': 'group'}),
} }
def __init__(self, *args, **kwargs):
super(RackForm, self).__init__(*args, **kwargs)
# Limit rack group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class RackFromCSVForm(forms.ModelForm): class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
@ -538,33 +535,54 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
# Devices # Devices
# #
class DeviceForm(BootstrapMixin, CustomFieldForm): class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) site = forms.ModelChoiceField(
rack = forms.ModelChoiceField( queryset=Site.objects.all(),
queryset=Rack.objects.all(), required=False, widget=APISelect( widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}', api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name', display_field='display_name',
attrs={'filter-for': 'position'} attrs={'filter-for': 'position'}
) )
) )
position = forms.TypedChoiceField( position = forms.TypedChoiceField(
required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device", required=False,
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device') empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
disabled_indicator='device'
)
) )
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'}) queryset=Manufacturer.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'device_type'}
)
) )
device_type = forms.ModelChoiceField( device_type = ChainedModelChoiceField(
queryset=DeviceType.objects.all(), label='Device type', queryset=DeviceType.objects.all(),
widget=APISelect(api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', display_field='model') chains={'manufacturer': 'manufacturer'},
label='Device type',
widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
)
) )
comments = CommentField() comments = CommentField()
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
] ]
help_texts = { help_texts = {
'device_role': "The function this device serves", 'device_role': "The function this device serves",
@ -572,19 +590,22 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
} }
widgets = { widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}), 'face': forms.Select(attrs={'filter-for': 'position'}),
'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
if instance and hasattr(instance, 'device_type'):
initial = kwargs.get('initial', {})
initial['manufacturer'] = instance.device_type.manufacturer
kwargs['initial'] = initial
super(DeviceForm, self).__init__(*args, **kwargs) super(DeviceForm, self).__init__(*args, **kwargs)
if self.instance.pk: if self.instance.pk:
# Initialize helper selections
self.initial['site'] = self.instance.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]: for family in [4, 6]:
ip_choices = [] ip_choices = []
@ -607,14 +628,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Limit rack choices
if self.is_bound and self.data.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Rack position # Rack position
pk = self.instance.pk if self.instance.pk else None pk = self.instance.pk if self.instance.pk else None
try: try:
@ -635,16 +648,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
}) for p in position_choices }) for p in position_choices
] ]
# Limit device_type choices
if self.is_bound:
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
.select_related('manufacturer')
elif self.initial.get('manufacturer'):
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
.select_related('manufacturer')
else:
self.fields['device_type'].choices = []
# Disable rack assignment if this is a child device installed in a parent device # Disable rack assignment if this is a child device installed in a parent device
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True self.fields['site'].disabled = True
@ -811,6 +814,10 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
label='Rack group', label='Rack group',
) )
rack_id = FilterChoiceField(
queryset=Rack.objects.annotate(filter_count=Count('devices')),
label='Rack',
)
role = FilterChoiceField( role = FilterChoiceField(
queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
to_field_name='slug', to_field_name='slug',
@ -940,21 +947,23 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
self.cleaned_data['csv'] = connection_list self.cleaned_data['csv'] = connection_list
class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
widget=forms.HiddenInput(), widget=forms.HiddenInput(),
) )
rack = forms.ModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack', label='Rack',
required=False, required=False,
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'console_server', 'nullable': 'true'} attrs={'filter-for': 'console_server', 'nullable': 'true'}
) )
) )
console_server = forms.ModelChoiceField( console_server = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.filter(device_type__is_console_server=True),
chains={'site': 'site', 'rack': 'rack'},
label='Console Server', label='Console Server',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -972,8 +981,9 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='console_server', field_to_update='console_server',
) )
) )
cs_port = forms.ModelChoiceField( cs_port = ChainedModelChoiceField(
queryset=ConsoleServerPort.objects.all(), queryset=ConsoleServerPort.objects.all(),
chains={'device': 'console_server'},
label='Port', label='Port',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
@ -996,32 +1006,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk: if not self.instance.pk:
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize console_server choices if rack or site is set
if self.initial.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(
rack=self.initial['rack'], device_type__is_console_server=True
)
elif self.initial.get('site'):
self.fields['console_server'].queryset = Device.objects.filter(
site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True
)
else:
self.fields['console_server'].choices = []
# Initialize CS port choices if console_server is set
if self.initial.get('console_server'):
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(
device=self.initial['console_server']
)
else:
self.fields['cs_port'].choices = []
# #
# Console server ports # Console server ports
@ -1041,21 +1025,23 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
widget=forms.HiddenInput(), widget=forms.HiddenInput(),
) )
rack = forms.ModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack', label='Rack',
required=False, required=False,
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'} attrs={'filter-for': 'device', 'nullable': 'true'}
) )
) )
device = forms.ModelChoiceField( device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='Device', label='Device',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1073,8 +1059,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
field_to_update='device' field_to_update='device'
) )
) )
port = forms.ModelChoiceField( port = ChainedModelChoiceField(
queryset=ConsolePort.objects.all(), queryset=ConsolePort.objects.all(),
chains={'device': 'device'},
label='Port', label='Port',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/console-ports/?device_id={{device}}', api_url='/api/dcim/console-ports/?device_id={{device}}',
@ -1096,30 +1083,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
'connection_status': 'Status', 'connection_status': 'Status',
} }
def __init__(self, *args, **kwargs):
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize device choices if rack or site is set
if self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
elif self.initial.get('site'):
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
else:
self.fields['device'].choices = []
# Initialize port choices if device is set
if self.initial.get('device'):
self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device'])
else:
self.fields['port'].choices = []
# #
# Power ports # Power ports
@ -1211,18 +1174,20 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
self.cleaned_data['csv'] = connection_list self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput()) site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
rack = forms.ModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack', label='Rack',
required=False, required=False,
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'pdu', 'nullable': 'true'} attrs={'filter-for': 'pdu', 'nullable': 'true'}
) )
) )
pdu = forms.ModelChoiceField( pdu = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='PDU', label='PDU',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1240,8 +1205,9 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='pdu' field_to_update='pdu'
) )
) )
power_outlet = forms.ModelChoiceField( power_outlet = ChainedModelChoiceField(
queryset=PowerOutlet.objects.all(), queryset=PowerOutlet.objects.all(),
chains={'device': 'device'},
label='Outlet', label='Outlet',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/power-outlets/?device_id={{pdu}}', api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
@ -1264,30 +1230,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk: if not self.instance.pk:
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize pdu choices if rack or site is set
if self.initial.get('rack'):
self.fields['pdu'].queryset = Device.objects.filter(
rack=self.initial['rack'], device_type__is_pdu=True
)
elif self.initial.get('site'):
self.fields['pdu'].queryset = Device.objects.filter(
site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True
)
else:
self.fields['pdu'].choices = []
# Initialize power outlet choices if pdu is set
if self.initial.get('pdu'):
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu'])
else:
self.fields['power_outlet'].choices = []
# #
# Power outlets # Power outlets
@ -1307,21 +1249,23 @@ class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(BootstrapMixin, forms.Form): class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
widget=forms.HiddenInput() widget=forms.HiddenInput()
) )
rack = forms.ModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack', label='Rack',
required=False, required=False,
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'} attrs={'filter-for': 'device', 'nullable': 'true'}
) )
) )
device = forms.ModelChoiceField( device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='Device', label='Device',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1339,8 +1283,9 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
field_to_update='device' field_to_update='device'
) )
) )
port = forms.ModelChoiceField( port = ChainedModelChoiceField(
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
chains={'device': 'device'},
label='Port', label='Port',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/power-ports/?device_id={{device}}', api_url='/api/dcim/power-ports/?device_id={{device}}',
@ -1362,30 +1307,6 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
'connection_status': 'Status', 'connection_status': 'Status',
} }
def __init__(self, *args, **kwargs):
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize device choices if rack or site is set
if self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
elif self.initial.get('site'):
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
else:
self.fields['device'].choices = []
# Initialize port choices if device is set
if self.initial.get('device'):
self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device'])
else:
self.fields['port'].choices = []
# #
# Interfaces # Interfaces
@ -1468,7 +1389,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
# Interface connections # Interface connections
# #
class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
interface_a = forms.ChoiceField( interface_a = forms.ChoiceField(
choices=[], choices=[],
widget=SelectWithDisabled, widget=SelectWithDisabled,
@ -1482,8 +1403,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'rack_b'} attrs={'filter-for': 'rack_b'}
) )
) )
rack_b = forms.ModelChoiceField( rack_b = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site_b'},
label='Rack', label='Rack',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1491,8 +1413,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device_b', 'nullable': 'true'} attrs={'filter-for': 'device_b', 'nullable': 'true'}
) )
) )
device_b = forms.ModelChoiceField( device_b = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site_b', 'rack': 'rack_b'},
label='Device', label='Device',
required=False, required=False,
widget=APISelect( widget=APISelect(
@ -1510,8 +1433,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='device_b' field_to_update='device_b'
) )
) )
interface_b = forms.ModelChoiceField( interface_b = ChainedModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
),
chains={'device': 'device_b'},
label='Interface', label='Interface',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
@ -1537,31 +1463,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
] ]
# Initialize rack_b choices if site_b is set # Mark connected interfaces as disabled
if self.initial.get('site_b'):
self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
else:
self.fields['rack_b'].choices = []
# Initialize device_b choices if rack_b or site_b is set
if self.initial.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
elif self.initial.get('site_b'):
self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True)
else:
self.fields['device_b'].choices = []
# Initialize interface_b choices if device_b is set
if self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
else:
device_b_interfaces = []
self.fields['interface_b'].choices = [ self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
] ]

View File

@ -410,7 +410,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
] ]
def __str__(self): def __str__(self):
return self.display_name return self.display_name or super(Rack, self).__str__()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk]) return reverse('dcim:rack', args=[self.pk])
@ -467,7 +467,9 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
def display_name(self): def display_name(self):
if self.facility_id: if self.facility_id:
return u"{} ({})".format(self.name, self.facility_id) return u"{} ({})".format(self.name, self.facility_id)
return self.name elif self.name:
return self.name
return u""
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
""" """
@ -810,13 +812,13 @@ class InterfaceManager(models.Manager):
def order_naturally(self, method=IFACE_ORDERING_POSITION): def order_naturally(self, method=IFACE_ORDERING_POSITION):
""" """
Naturally order interfaces by their name and numeric position. The sort method must be one of the defined Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name), To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit: slot, subslot, position, channel, and virtual circuit:
{name}{slot}/{subslot}/{position}:{channel}.{vc} {type}{slot}/{subslot}/{position}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows: be parsed as follows:
@ -828,16 +830,17 @@ class InterfaceManager(models.Manager):
channel = None channel = None
vc = 0 vc = 0
The chosen sorting method will determine which fields are ordered first in the query. The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
the prescribed fields.
""" """
queryset = self.get_queryset() queryset = self.get_queryset()
sql_col = '{}.name'.format(queryset.model._meta.db_table) sql_col = '{}.name'.format(queryset.model._meta.db_table)
ordering = { ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'), IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'), IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
}[method] }[method]
return queryset.extra(select={ return queryset.extra(select={
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
@ -983,7 +986,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
unique_together = ['rack', 'position', 'face'] unique_together = ['rack', 'position', 'face']
def __str__(self): def __str__(self):
return self.display_name return self.display_name or super(Device, self).__str__()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk]) return reverse('dcim:device', args=[self.pk])
@ -1102,12 +1105,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def display_name(self): def display_name(self):
if self.name: if self.name:
return self.name return self.name
elif self.position: elif hasattr(self, 'device_type'):
return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position) return u"{}".format(self.device_type)
elif self.rack: return u""
return u"{} ({})".format(self.device_type, self.rack.name)
else:
return u"{} ({})".format(self.device_type, self.site.name)
@property @property
def identifier(self): def identifier(self):

View File

@ -105,6 +105,9 @@ class ComponentCreateView(View):
new_components.append(component_form.save(commit=False)) new_components.append(component_form.save(commit=False))
else: else:
for field, errors in component_form.errors.as_data().items(): for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form
if field == 'name':
field = 'name_pattern'
for e in errors: for e in errors:
form.add_error(field, u'{}: {}'.format(name, ', '.join(e))) form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))

View File

@ -13,6 +13,8 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from utilities.utils import foreground_color
CUSTOMFIELD_MODELS = ( CUSTOMFIELD_MODELS = (
'site', 'rack', 'devicetype', 'device', # DCIM 'site', 'rack', 'devicetype', 'device', # DCIM
@ -316,7 +318,7 @@ class TopologyMap(models.Model):
def render(self, img_format='png'): def render(self, img_format='png'):
from circuits.models import CircuitTermination from circuits.models import CircuitTermination
from dcim.models import Device, InterfaceConnection from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph # Construct the graph
graph = graphviz.Graph() graph = graphviz.Graph()
@ -336,8 +338,9 @@ class TopologyMap(models.Model):
for query in device_set.split(';'): # Split regexes on semicolons for query in device_set.split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role') devices += Device.objects.filter(name__regex=query).select_related('device_role')
for d in devices: for d in devices:
fillcolor = '#{}'.format(d.device_role.color) bg_color = '#{}'.format(d.device_role.color)
subgraph.node(d.name, style='filled', fillcolor=fillcolor) fg_color = '#{}'.format(foreground_color(d.device_role.color))
subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
# Add an invisible connection to each successive device in a set to enforce horizontal order # Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1): for j in range(0, len(devices) - 1):
@ -357,7 +360,8 @@ class TopologyMap(models.Model):
interface_a__device__in=devices, interface_b__device__in=devices interface_a__device__in=devices, interface_b__device__in=devices
) )
for c in connections: for c in connections:
graph.edge(c.interface_a.device.name, c.interface_b.device.name) style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph # Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):

View File

@ -3,10 +3,11 @@ from django.db.models import Count
from dcim.models import Site, Rack, Device, Interface from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, CSVDataField, ExpandableIPAddressField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
) )
from .models import ( from .models import (
@ -32,11 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
# VRFs # VRFs
# #
class VRFForm(BootstrapMixin, CustomFieldForm): class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
labels = { labels = {
'rd': "RD", 'rd': "RD",
} }
@ -163,30 +164,27 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
# Prefixes # Prefixes
# #
class PrefixForm(BootstrapMixin, CustomFieldForm): class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', site = forms.ModelChoiceField(
widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'})) queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', attrs={'filter-for': 'vlan', 'nullable': 'true'}
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', )
display_field='display_name')) )
vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
)
)
class Meta: class Meta:
model = Prefix model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description'] fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(PrefixForm, self).__init__(*args, **kwargs) super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
# Initialize field without choices to avoid pulling all VLANs from the database
if self.is_bound and self.data.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
class PrefixFromCSVForm(forms.ModelForm): class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@ -214,7 +212,6 @@ class PrefixFromCSVForm(forms.ModelForm):
vlan_group_name = self.cleaned_data.get('vlan_group_name') vlan_group_name = self.cleaned_data.get('vlan_group_name')
vlan_vid = self.cleaned_data.get('vlan_vid') vlan_vid = self.cleaned_data.get('vlan_vid')
vlan_group = None vlan_group = None
vlan = None
# Validate VLAN group # Validate VLAN group
if vlan_group_name: if vlan_group_name:
@ -310,85 +307,123 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
# IP addresses # IP addresses
# #
class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
interface_site = forms.ModelChoiceField( interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'interface_rack'} attrs={'filter-for': 'interface_rack'}
) )
) )
interface_rack = forms.ModelChoiceField( interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect( queryset=Rack.objects.all(),
api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name', chains={'site': 'interface_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'interface_device', 'nullable': 'true'} attrs={'filter-for': 'interface_device', 'nullable': 'true'}
) )
) )
interface_device = forms.ModelChoiceField( interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(), required=False, label='Device', widget=APISelect( queryset=Device.objects.all(),
chains={'site': 'interface_site', 'rack': 'interface_rack'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}', api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name', attrs={'filter-for': 'interface'} display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
interface = ChainedModelChoiceField(
queryset=Interface.objects.all(),
chains={'device': 'interface_device'},
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
) )
) )
nat_site = forms.ModelChoiceField( nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'nat_device'} attrs={'filter-for': 'nat_device'}
) )
) )
nat_device = forms.ModelChoiceField( nat_rack = ChainedModelChoiceField(
queryset=Device.objects.all(), required=False, label='Device', widget=APISelect( queryset=Rack.objects.all(),
api_url='/api/dcim/devices/?site_id={{nat_site}}', display_field='display_name', chains={'site': 'nat_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_device', 'nullable': 'true'}
)
)
nat_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'nat_site'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'} attrs={'filter-for': 'nat_inside'}
) )
) )
nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(),
chains={'interface__device': 'nat_device'},
required=False,
label='IP Address',
widget=APISelect(
api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'
)
)
livesearch = forms.CharField( livesearch = forms.CharField(
required=False, label='IP Address', widget=Livesearch( required=False,
query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address' label='IP Address',
widget=Livesearch(
query_key='q',
query_url='ipam-api:ipaddress-list',
field_to_update='nat_inside',
obj_label='address'
) )
) )
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device') primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside'] fields = [
widgets = { 'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group',
'interface': APISelect(api_url='/api/dcim/interfaces/?device_id={{interface_device}}'), 'tenant',
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address') ]
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance and instance.interface is not None:
initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside 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(IPAddressForm, self).__init__(*args, **kwargs) super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
# If an interface has been assigned, initialize site, rack, and device
if self.instance.interface:
self.initial['interface_site'] = self.instance.interface.device.site
self.initial['interface_rack'] = self.instance.interface.device.rack
self.initial['interface_device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound and self.data.get('interface_site'):
self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site'])
elif self.initial.get('interface_site'):
self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site'])
else:
self.fields['interface_rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('interface_rack'):
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack'])
elif self.initial.get('interface_rack'):
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack'])
else:
self.fields['interface_device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('interface_device'):
self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device'])
elif self.initial.get('interface_device'):
self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device'])
else:
self.fields['interface'].choices = []
# Initialize primary_for_device if IP address is already assigned # Initialize primary_for_device if IP address is already assigned
if self.instance.interface is not None: if self.instance.interface is not None:
device = self.instance.interface.device device = self.instance.interface.device
@ -398,38 +433,6 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
): ):
self.initial['primary_for_device'] = True self.initial['primary_for_device'] = True
if self.instance.nat_inside:
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
site=nat_inside.interface.device.site
)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device
)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
else:
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
# Initialize nat_inside choices if nat_device is set
if self.is_bound and self.data.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.data['nat_device'])
elif self.initial.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.initial['nat_device'])
else:
self.fields['nat_inside'].choices = []
def clean(self): def clean(self):
super(IPAddressForm, self).clean() super(IPAddressForm, self).clean()
@ -468,15 +471,19 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
return ipaddress return ipaddress
class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm): class IPAddressPatternForm(BootstrapMixin, forms.Form):
address_pattern = ExpandableIPAddressField(label='Address Pattern') pattern = ExpandableIPAddressField(label='Address pattern')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
pattern_map = ('address_pattern', 'address')
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description'] fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs):
super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPAddressFromCSVForm(forms.ModelForm): class IPAddressFromCSVForm(forms.ModelForm):
@ -602,14 +609,26 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
# VLANs # VLANs
# #
class VLANForm(BootstrapMixin, CustomFieldForm): class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect( site = forms.ModelChoiceField(
api_url='/api/ipam/vlan-groups/?site_id={{site}}', queryset=Site.objects.all(),
)) widget=forms.Select(
attrs={'filter-for': 'group', 'nullable': 'true'}
)
)
group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains={'site': 'site'},
required=False,
label='Group',
widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
help_texts = { help_texts = {
'site': "Leave blank if this VLAN spans multiple sites", 'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)", 'group': "VLAN group (optional)",
@ -618,21 +637,6 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
'status': "Operational status of this VLAN", 'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN", 'role': "The primary function of this VLAN",
} }
widgets = {
'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
}
def __init__(self, *args, **kwargs):
super(VLANForm, self).__init__(*args, **kwargs)
# Limit VLAN group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
class VLANFromCSVForm(forms.ModelForm): class VLANFromCSVForm(forms.ModelForm):
@ -663,7 +667,7 @@ class VLANFromCSVForm(forms.ModelForm):
group_name = self.cleaned_data.get('group_name') group_name = self.cleaned_data.get('group_name')
if group_name: if group_name:
try: try:
vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name) VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
except VLANGroup.DoesNotExist: except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name)) self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
@ -697,7 +701,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta: class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description'] nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
def vlan_status_choices(): def vlan_status_choices():

View File

@ -538,7 +538,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name_plural = 'VLANs' verbose_name_plural = 'VLANs'
def __str__(self): def __str__(self):
return self.display_name return self.display_name or super(VLAN, self).__str__()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk]) return reverse('ipam:vlan', args=[self.pk])
@ -565,7 +565,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@property @property
def display_name(self): def display_name(self):
return u'{} ({})'.format(self.vid, self.name) if self.vid and self.name:
return u"{} ({})".format(self.vid, self.name)
return None
def get_status_class(self): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] return STATUS_CHOICE_CLASSES[self.status]

View File

@ -76,6 +76,15 @@ IPADDRESS_LINK = """
{% endif %} {% endif %}
""" """
IPADDRESS_DEVICE = """
{% if record.interface %}
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
({{ record.interface.name }})
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """ VRF_LINK = """
{% if record.vrf %} {% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a> <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@ -281,12 +290,14 @@ class IPAddressTable(BaseTable):
status = tables.TemplateColumn(STATUS_LABEL) status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK) tenant = tables.TemplateColumn(TENANT_LINK)
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) nat_inside = tables.LinkColumn(
interface = tables.Column(orderable=False) 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
)
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description')
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
} }

View File

@ -2,15 +2,12 @@ from django_tables2 import RequestConfig
import netaddr import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib import messages
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, render
from django.urls import reverse from django.urls import reverse
from dcim.models import Device from dcim.models import Device
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@ -536,7 +533,7 @@ def prefix_ipaddresses(request, pk):
# #
class IPAddressListView(ObjectListView): class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
filter = filters.IPAddressFilter filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable table = tables.IPAddressTable
@ -587,8 +584,9 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
permission_required = 'ipam.add_ipaddress' permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkAddForm pattern_form = forms.IPAddressPatternForm
model_form = forms.IPAddressForm model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
template_name = 'ipam/ipaddress_bulk_add.html' template_name = 'ipam/ipaddress_bulk_add.html'
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'

View File

@ -13,7 +13,7 @@ except ImportError:
) )
VERSION = '2.0.1' VERSION = '2.0.2'
# Import local configuration # Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

View File

@ -36,7 +36,7 @@ SEARCH_TYPES = {
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
}, },
'circuit': { 'circuit': {
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'), 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
'filter': CircuitFilter, 'filter': CircuitFilter,
'table': CircuitSearchTable, 'table': CircuitSearchTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',

View File

@ -44,6 +44,7 @@ class SecretTable(BaseTable):
class SecretSearchTable(SearchTable): class SecretSearchTable(SearchTable):
device = tables.LinkColumn()
class Meta(SearchTable.Meta): class Meta(SearchTable.Meta):
model = Secret model = Secret

View File

@ -8,12 +8,18 @@
{% render_field form.provider %} {% render_field form.provider %}
{% render_field form.cid %} {% render_field form.cid %}
{% render_field form.type %} {% render_field form.type %}
{% render_field form.tenant %}
{% render_field form.install_date %} {% render_field form.install_date %}
{% render_field form.commit_rate %} {% render_field form.commit_rate %}
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -7,7 +7,6 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.name %} {% render_field form.name %}
{% render_field form.device_role %} {% render_field form.device_role %}
{% render_field form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
@ -63,6 +62,13 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -31,24 +31,83 @@
{% block javascript %} {% block javascript %}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_rack_group_id');
var rack_list = $('#id_rack_id');
var manufacturer_list = $('#id_manufacturer_id');
var model_list = $('#id_device_type_id'); var model_list = $('#id_device_type_id');
$('#id_manufacturer_id').change(function() {
model_list.empty(); // Update device type options based on selected manufacturer
manufacturer_list.change(function() {
var selected_manufacturers = $(this).val(); var selected_manufacturers = $(this).val();
if (selected_manufacturers) { if (selected_manufacturers) {
var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id='); model_list.empty();
$.ajax({ $.ajax({
url: api_url, url: netbox_api_path + 'dcim/device-types/?limit=500&manufacturer_id=' + selected_manufacturers.join('&manufacturer_id='),
dataType: 'json', dataType: 'json',
success: function (response, status) { success: function (response, status) {
$.each(response, function (index, device_type) { $.each(response["results"], function (index, device_type) {
var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")"); var option = $("<option></option>").attr("value", device_type.id).text(device_type.model + " (" + device_type.instance_count + ")");
model_list.append(option); model_list.append(option);
}); });
} }
}); });
} }
}); });
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
// Update rack options
rack_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/racks/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, rack) {
var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
rack_list.append(option);
});
}
});
}
});
// Update rack options based on selected rack group
rack_group_list.change(function() {
var selected_rack_groups = $(this).val();
if (selected_rack_groups) {
rack_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/racks/?limit=500&group_id=' + selected_rack_groups.join('&group_id='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, rack) {
var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
rack_list.append(option);
});
}
});
}
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -134,7 +134,7 @@
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span> <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td> </td>
<td class="text-right"> <td class="text-right">
{% if perms.ipam.edit_ipaddress %} {% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs"> <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a> </a>

View File

@ -6,11 +6,22 @@
<div class="panel-heading"><strong>Rack</strong></div> <div class="panel-heading"><strong>Rack</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.site %} {% render_field form.site %}
{% render_field form.group %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.facility_id %} {% render_field form.facility_id %}
{% render_field form.tenant %} {% render_field form.group %}
{% render_field form.role %} {% render_field form.role %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Dimensions</strong></div>
<div class="panel-body">
{% render_field form.type %} {% render_field form.type %}
{% render_field form.width %} {% render_field form.width %}
{% render_field form.u_height %} {% render_field form.u_height %}

View File

@ -14,7 +14,7 @@
{% for rack in page %} {% for rack in page %}
<div style="display: inline-block; width: 266px"> <div style="display: inline-block; width: 266px">
<div class="rack_header"> <div class="rack_header">
<h4>{{ rack.name }}</h4> <h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
</div> </div>
{% if face_id %} {% if face_id %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %} {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %}
@ -23,7 +23,7 @@
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="rack_header"> <div class="rack_header">
<h4>{{ rack.name }}</h4> <h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -8,11 +8,17 @@
{% render_field form.name %} {% render_field form.name %}
{% render_field form.slug %} {% render_field form.slug %}
{% render_field form.region %} {% render_field form.region %}
{% render_field form.tenant %}
{% render_field form.facility %} {% render_field form.facility %}
{% render_field form.asn %} {% render_field form.asn %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Contact Info</strong></div> <div class="panel-heading"><strong>Contact Info</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -12,18 +12,24 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>IP Addresses</strong></div> <div class="panel-heading"><strong>IP Addresses</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.address_pattern %} {% render_field pattern_form.pattern %}
{% render_field form.vrf %} {% render_field model_form.status %}
{% render_field form.tenant %} {% render_field model_form.vrf %}
{% render_field form.status %} {% render_field model_form.description %}
{% render_field form.description %}
</div> </div>
</div> </div>
{% if form.custom_fields %} <div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field model_form.tenant_group %}
{% render_field model_form.tenant %}
</div>
</div>
{% if model_form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_custom_fields form %} {% render_custom_fields model_form %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -13,12 +13,18 @@
<div class="panel-heading"><strong>IP Address</strong></div> <div class="panel-heading"><strong>IP Address</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.address %} {% render_field form.address %}
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.status %} {% render_field form.status %}
{% render_field form.vrf %}
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Interface Assignment</strong> <strong>Interface Assignment</strong>

View File

@ -6,14 +6,20 @@
<div class="panel-heading"><strong>Prefix</strong></div> <div class="panel-heading"><strong>Prefix</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.prefix %} {% render_field form.prefix %}
{% render_field form.status %}
{% render_field form.vrf %} {% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.vlan %} {% render_field form.vlan %}
{% render_field form.status %}
{% render_field form.role %} {% render_field form.role %}
{% render_field form.is_pool %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.is_pool %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div> </div>
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}

View File

@ -9,12 +9,18 @@
{% render_field form.group %} {% render_field form.group %}
{% render_field form.vid %} {% render_field form.vid %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.tenant %}
{% render_field form.status %} {% render_field form.status %}
{% render_field form.role %} {% render_field form.role %}
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -7,11 +7,17 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.name %} {% render_field form.name %}
{% render_field form.rd %} {% render_field form.rd %}
{% render_field form.tenant %}
{% render_field form.enforce_unique %} {% render_field form.enforce_unique %}
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -7,7 +7,7 @@
<nav> <nav>
<ul class="pagination pull-right"> <ul class="pagination pull-right">
{% if table.page.has_previous %} {% if table.page.has_previous %}
<li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li> <li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"><i class="fa fa-angle-double-left"></i></a></li>
{% endif %} {% endif %}
{% for p in table.page.smart_pages %} {% for p in table.page.smart_pages %}
{% if p %} {% if p %}
@ -17,18 +17,20 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if table.page.has_next %} {% if table.page.has_next %}
<li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">&raquo;</a></li> <li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"><i class="fa fa-angle-double-right"></i></a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="text-right text-muted"> <div class="text-right text-muted">
Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }} {% with table.page.paginator.count as total %}
{% if total == 1 %} Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
{{ table.data.verbose_name }} {% if total == 1 %}
{% else %} {{ table.data.verbose_name }}
{{ table.data.verbose_name_plural }} {% else %}
{% endif %} {{ table.data.verbose_name_plural }}
{% endif %}
{% endwith %}
</div> </div>
</div> </div>

View File

@ -2,8 +2,10 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
FilterChoiceField, SlugField,
)
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
@ -61,3 +63,36 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug', to_field_name='slug',
null_option=(0, 'None') null_option=(0, 'None')
) )
#
# Tenancy form extension
#
class TenancyForm(ChainedFieldsMixin, forms.Form):
tenant_group = forms.ModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'tenant', 'nullable': 'true'}
)
)
tenant = ChainedModelChoiceField(
queryset=Tenant.objects.all(),
chains={'group': 'tenant_group'},
required=False,
widget=APISelect(
api_url='/api/tenancy/tenants/?group_id={{tenant_group}}'
)
)
def __init__(self, *args, **kwargs):
# Initialize helper selector
instance = kwargs.get('instance')
if instance and instance.tenant is not None:
initial = kwargs.get('initial', {})
initial['tenant_group'] = instance.tenant.group
kwargs['initial'] = initial
super(TenancyForm, self).__init__(*args, **kwargs)

View File

@ -44,6 +44,7 @@ class TenantTable(BaseTable):
class TenantSearchTable(SearchTable): class TenantSearchTable(SearchTable):
name = tables.LinkColumn()
class Meta(SearchTable.Meta): class Meta(SearchTable.Meta):
model = Tenant model = Tenant

View File

@ -216,10 +216,13 @@ class TokenEditView(LoginRequiredMixin, View):
token.user = request.user token.user = request.user
token.save() token.save()
msg = "Token updated" if pk else "New token created" msg = "Modified token {}".format(token) if pk else "Created token {}".format(token)
messages.success(request, msg) messages.success(request, msg)
return redirect('user:token_list') if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect('user:token_list')
class TokenDeleteView(LoginRequiredMixin, View): class TokenDeleteView(LoginRequiredMixin, View):

View File

@ -331,6 +331,25 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
return value return value
class ChainedModelChoiceField(forms.ModelChoiceField):
"""
A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
mapping of model fields to peer fields within the form. For example:
country1 = forms.ModelChoiceField(queryset=Country.objects.all())
city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
The queryset of the `city1` field will be modified as
.filter(country=<value>)
where <value> is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
"""
def __init__(self, chains=None, *args, **kwargs):
self.chains = chains
super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
class SlugField(forms.SlugField): class SlugField(forms.SlugField):
def __init__(self, slug_source='name', *args, **kwargs): def __init__(self, slug_source='name', *args, **kwargs):
@ -411,6 +430,32 @@ class BootstrapMixin(forms.BaseForm):
field.widget.attrs['placeholder'] = field.label field.widget.attrs['placeholder'] = field.label
class ChainedFieldsMixin(forms.BaseForm):
"""
Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
"""
def __init__(self, *args, **kwargs):
super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if isinstance(field, ChainedModelChoiceField):
filters_dict = {}
for db_field, parent_field in field.chains.items():
if self.is_bound:
filters_dict[db_field] = self.data.get(parent_field) or None
elif self.initial.get(parent_field):
filters_dict[db_field] = self.initial[parent_field]
else:
filters_dict[db_field] = None
if filters_dict:
field.queryset = field.queryset.filter(**filters_dict)
else:
field.queryset = field.queryset.none()
class ReturnURLForm(forms.Form): class ReturnURLForm(forms.Form):
""" """
Provides a hidden return URL field to control where the user is directed after the form is submitted. Provides a hidden return URL field to control where the user is directed after the form is submitted.

View File

@ -24,3 +24,15 @@ def csv_format(data):
csv.append(u'{}'.format(value)) csv.append(u'{}'.format(value))
return u','.join(csv) return u','.join(csv)
def foreground_color(bg_color):
"""
Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.
"""
bg_color = bg_color.strip('#')
r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
if r * 0.299 + g * 0.587 + b * 0.114 > 186:
return '000000'
else:
return 'ffffff'

View File

@ -290,66 +290,78 @@ class BulkAddView(View):
""" """
Create new objects in bulk. Create new objects in bulk.
form: Form class pattern_form: Form class which provides the `pattern` field
model_form: The ModelForm used to create individual objects model_form: The ModelForm used to create individual objects
template_name: The name of the template template_name: The name of the template
default_return_url: Name of the URL to which the user is redirected after creating the objects default_return_url: Name of the URL to which the user is redirected after creating the objects
""" """
form = None pattern_form = None
model_form = None model_form = None
pattern_target = ''
template_name = None template_name = None
default_return_url = 'home' default_return_url = 'home'
def get(self, request): def get(self, request):
form = self.form() pattern_form = self.pattern_form()
model_form = self.model_form()
return render(request, self.template_name, { return render(request, self.template_name, {
'obj_type': self.model_form._meta.model._meta.verbose_name, 'obj_type': self.model_form._meta.model._meta.verbose_name,
'form': form, 'pattern_form': pattern_form,
'model_form': model_form,
'return_url': reverse(self.default_return_url), 'return_url': reverse(self.default_return_url),
}) })
def post(self, request): def post(self, request):
model = self.model_form._meta.model model = self.model_form._meta.model
form = self.form(request.POST) pattern_form = self.pattern_form(request.POST)
if form.is_valid(): model_form = self.model_form(request.POST)
# Read the pattern field and target from the form's pattern_map if pattern_form.is_valid():
pattern_field, pattern_target = form.pattern_map
pattern = form.cleaned_data[pattern_field]
model_form_data = form.cleaned_data
pattern = pattern_form.cleaned_data['pattern']
new_objs = [] new_objs = []
try: try:
with transaction.atomic(): with transaction.atomic():
# Validate and save each object individually
# Create objects from the expanded. Abort the transaction on the first validation error.
for value in pattern: for value in pattern:
model_form_data[pattern_target] = value
model_form = self.model_form(model_form_data) # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
# copy of the POST QueryDict so that we can update the target field value.
model_form = self.model_form(request.POST.copy())
model_form.data[self.pattern_target] = value
# Validate each new object independently.
if model_form.is_valid(): if model_form.is_valid():
obj = model_form.save() obj = model_form.save()
new_objs.append(obj) new_objs.append(obj)
else: else:
for error in model_form.errors.as_data().values(): # Copy any errors on the pattern target field to the pattern form.
form.add_error(None, error) errors = model_form.errors.as_data()
# Abort the creation of all objects if errors exist if errors.get(self.pattern_target):
if form.errors: pattern_form.add_error('pattern', errors[self.pattern_target])
raise ValidationError("Validation of one or more model forms failed.") # Raise an IntegrityError to break the for loop and abort the transaction.
except ValidationError: raise IntegrityError()
# If we make it to this point, validation has succeeded on all new objects.
msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
messages.success(request, msg)
UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST:
return redirect(request.path)
return redirect(self.default_return_url)
except IntegrityError:
pass pass
if not form.errors:
msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
messages.success(request, msg)
UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST:
return redirect(request.path)
return redirect(self.default_return_url)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'pattern_form': pattern_form,
'model_form': model_form,
'obj_type': model._meta.verbose_name, 'obj_type': model._meta.verbose_name,
'return_url': reverse(self.default_return_url), 'return_url': reverse(self.default_return_url),
}) })