Compare commits

...

49 Commits

Author SHA1 Message Date
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
9cf10eecd1 Release v2.0.3 2017-05-18 14:31:48 -04:00
Jeremy Stretch
f927d5b8f5 Closes #1198: Allow filtering unracked devices on device list 2017-05-18 14:27:07 -04:00
Jeremy Stretch
7fa696dace Fixes #1195: Unable to create an interface connection when searching for peer device 2017-05-18 13:33:26 -04:00
Jeremy Stretch
feac93389c Fixes #1200: Form validation error when connecting power ports to power outlets 2017-05-18 12:11:14 -04:00
Jeremy Stretch
f7969d91b3 Fixes #1199: Bulk import of secrets does not prompt user to generate a session key 2017-05-18 09:17:41 -04:00
Jeremy Stretch
92aafb9043 Added WSGIPassAuthorization to example Apache config 2017-05-17 17:23:08 -04:00
Jeremy Stretch
f9328d53b4 Fixes #1197: Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs 2017-05-17 17:16:02 -04:00
Jeremy Stretch
f1cbc7da33 Fixes #1157: Hide nav menu search bar on small displays 2017-05-17 16:00:46 -04:00
Jeremy Stretch
01becd21de Closes #1196: Added a lag_id filter to the API interfaces view 2017-05-17 14:43:44 -04:00
Jeremy Stretch
7768b94279 Fixes #1188: Serialize interface LAG as nested objected (API) 2017-05-17 14:32:39 -04:00
Jeremy Stretch
3bc51c8e69 Fixes #1191: Bulk selection of IPs under a prefix incorrect when "select all" is used 2017-05-17 14:23:08 -04:00
Jeremy Stretch
d206be91d5 Fixes #1130: Added zlib1g-dev to Ubuntu/Debian packages list 2017-05-17 12:48:31 -04:00
Jeremy Stretch
6e69c9e375 Restored the option to hide the paginator on panel tables 2017-05-17 12:18:32 -04:00
Jeremy Stretch
f2846af4ec Fixes #1189: Enforce consistent ordering of objects returned by a global search 2017-05-17 12:16:57 -04:00
Jeremy Stretch
657eed1dc9 Merge pull request #1185 from ryanmerolle/patch-1
Added vagrant alternative installation link
2017-05-16 16:53:01 -04:00
Jeremy Stretch
e351ab0171 Fixes #1186: Corrected VLAN edit form so that site assignment is not required 2017-05-16 16:30:28 -04:00
Jeremy Stretch
779446da64 Fixes #1187: Fixed table pagination by introducing a custom table template 2017-05-16 16:19:55 -04:00
ryanmerolle
2ff0d7aa83 Added vagrant alternative installation link 2017-05-16 07:13:05 -04:00
Jeremy Stretch
7ceb64b57b Post-release version bump 2017-05-15 13:24:37 -04:00
Jeremy Stretch
43e1e0dbc8 Merge pull request #1181 from digitalocean/develop
Release v2.0.2
2017-05-15 13:23:33 -04:00
Jeremy Stretch
a1c12cfd77 Release v2.0.2 2017-05-15 13:19:18 -04:00
Jeremy Stretch
aa6ca21a34 PEP8 fix 2017-05-15 13:18:49 -04:00
Jeremy Stretch
a49521d683 #1177: Render planned connections as dashed lines on topology maps 2017-05-15 13:11:20 -04:00
Jeremy Stretch
3be6e5b015 Closes #1179: Adjust topology map text color based on node background 2017-05-15 12:56:16 -04:00
Jeremy Stretch
ca1725b98c Fixes #1178: Fix API representation of connected interface's form factor 2017-05-15 11:03:11 -04:00
Jeremy Stretch
d11dfe2ced Closes #1137: Allow filtering devices list by rack 2017-05-12 22:41:27 -04:00
Jeremy Stretch
ab30ba1e1b Fixed dynamic selection of device type filter on devices list 2017-05-12 22:20:21 -04:00
Jeremy Stretch
7f23cb9bf5 Closes #1122: Include NAT inside IPs in IP address list 2017-05-12 22:11:20 -04:00
Jeremy Stretch
c9d3cf301e Fixes #1173: Tweak interface manager to fall back to naive ordering 2017-05-12 16:10:18 -04:00
Jeremy Stretch
67282882fa Fixed RelatedObjectDoesNotExist error when trying to create a new device 2017-05-12 15:55:18 -04:00
Jeremy Stretch
73bf4f45c3 Adapted model get_display_name() to better handle unsaved instances 2017-05-12 15:31:34 -04:00
Jeremy Stretch
66ae62fb91 Closes #1172: Linkify racks in side-by-side elevations view 2017-05-12 14:19:37 -04:00
Jeremy Stretch
8bae804508 Closes #1170: Include A and Z sites for circuits in global search results 2017-05-12 12:12:47 -04:00
Jeremy Stretch
d87acc97c3 Fixes #1171: Allow removing site assignment when bulk editing VLANs 2017-05-12 12:06:37 -04:00
Jeremy Stretch
f9b2c59974 Moved tenancy to separate panel on bulk IP creation form 2017-05-12 12:04:06 -04:00
Jeremy Stretch
a870a3b918 Fixes #1166: Re-implemented bulk IP address creation 2017-05-12 12:00:26 -04:00
Jeremy Stretch
008ed34553 Fixes #1168: Total count of obejcts missing from list view paginator 2017-05-11 23:30:23 -04:00
Jeremy Stretch
e239045688 PEP8 fixes 2017-05-11 17:54:43 -04:00
Jeremy Stretch
ed80bfaf02 Fixed selector initializations for TenancyForms 2017-05-11 17:52:23 -04:00
Jeremy Stretch
473b35f9a3 Added tenant_group/tenant form section to all objects with tenancy 2017-05-11 17:35:20 -04:00
Jeremy Stretch
45bb7eec0b Corrected queryset filter when parent_field is None 2017-05-11 17:20:50 -04:00
Jeremy Stretch
58bb029666 Closes #1167: Introduced ChainedModelChoiceFields 2017-05-11 16:30:16 -04:00
Jeremy Stretch
0f97478b55 Fixes #1161: Fix "add another" behavior when creating an API token 2017-05-10 22:22:49 -04:00
Jeremy Stretch
9efa70a551 Fixes #1159: Only superusers can see "edit IP" buttons on the device interfaces list 2017-05-10 16:02:50 -04:00
Jeremy Stretch
ed65721085 Fixes #1160: Linkify secrets and tenants in global search results 2017-05-10 13:16:33 -04:00
Jeremy Stretch
83688fceb7 Fixes #1158: Exception thrown when creating a device component with an invalid name 2017-05-10 11:23:54 -04:00
Jeremy Stretch
088f75ba0c Added client_max_body_size to nginx config; removed statement disabling access logging 2017-05-10 11:11:03 -04:00
Jeremy Stretch
188cfa08a9 Post-release version bump 2017-05-09 22:48:14 -04:00
79 changed files with 821 additions and 696 deletions

View File

@@ -33,3 +33,4 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
* [Docker container](https://github.com/digitalocean/netbox-docker) * [Docker container](https://github.com/digitalocean/netbox-docker)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku)) * [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)

View File

@@ -5,14 +5,14 @@
Python 3: Python 3:
```no-highlight ```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev # apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1 # update-alternatives --install /usr/bin/python python /usr/bin/python3 1
``` ```
Python 2: Python 2:
```no-highlight ```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
``` ```
**CentOS/RHEL** **CentOS/RHEL**

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/;
@@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
Alias /static /opt/netbox/netbox/static Alias /static /opt/netbox/netbox/static
# Needed to allow token-based API authentication
WSGIPassAuthorization on
<Directory /opt/netbox/netbox/static> <Directory /opt/netbox/netbox/static>
Options Indexes FollowSymLinks MultiViews Options Indexes FollowSymLinks MultiViews
AllowOverride None AllowOverride None

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

@@ -581,9 +581,18 @@ class WritablePowerPortSerializer(serializers.ModelSerializer):
# Interfaces # Interfaces
# #
class NestedInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'name']
class InterfaceSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = NestedInterfaceSerializer()
connection = serializers.SerializerMethodField(read_only=True) connection = serializers.SerializerMethodField(read_only=True)
connected_interface = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True)
@@ -608,6 +617,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

@@ -477,6 +477,11 @@ class InterfaceFilter(DeviceComponentFilterSet):
method='filter_type', method='filter_type',
label='Interface type', label='Interface type',
) )
lag_id = django_filters.ModelMultipleChoiceFilter(
name='lag',
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
mac_address = django_filters.CharFilter( mac_address = django_filters.CharFilter(
method='_mac_address', method='_mac_address',
label='MAC address', label='MAC address',

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(
queryset=RackGroup.objects.all(),
chains={'site': 'site'},
required=False,
widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}', 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 = ChainedModelChoiceField(
queryset=DeviceType.objects.all(),
chains={'manufacturer': 'manufacturer'},
label='Device type',
widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
) )
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(), 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
@@ -671,7 +674,7 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
queryset=Platform.objects.all(), required=False, to_field_name='name', queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'} error_messages={'invalid_choice': 'Invalid platform.'}
) )
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in STATUS_CHOICES]) status = forms.CharField()
class Meta: class Meta:
fields = [] fields = []
@@ -689,8 +692,12 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
except DeviceType.DoesNotExist: except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name)) self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
def clean_status_name(self): def clean_status(self):
return dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
class DeviceFromCSVForm(BaseDeviceFromCSVForm): class DeviceFromCSVForm(BaseDeviceFromCSVForm):
@@ -704,8 +711,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
class Meta(BaseDeviceFromCSVForm.Meta): class Meta(BaseDeviceFromCSVForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'status_name', 'site', 'rack_name', 'position', 'face', 'site', 'rack_name', 'position', 'face',
] ]
def clean(self): def clean(self):
@@ -748,8 +755,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
class Meta(BaseDeviceFromCSVForm.Meta): class Meta(BaseDeviceFromCSVForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'status_name', 'parent', 'device_bay_name', 'parent', 'device_bay_name',
] ]
def clean(self): def clean(self):
@@ -811,12 +818,18 @@ 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',
null_option=(0, 'None'),
)
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',
) )
tenant = FilterChoiceField( tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', queryset=Tenant.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
null_option=(0, 'None'), null_option=(0, 'None'),
) )
manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
@@ -940,21 +953,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 +987,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 +1012,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 +1031,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 +1065,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 +1089,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 +1180,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 +1211,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': 'pdu'},
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 +1236,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 +1255,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 +1289,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 +1313,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 +1395,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 +1409,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 +1419,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,12 +1439,15 @@ 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',
disabled_indicator='is_connected' disabled_indicator='connection'
) )
) )
@@ -1537,31 +1469,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)
elif self.name:
return 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

@@ -1,12 +1,14 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count 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 +34,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 +165,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',
@@ -197,14 +196,16 @@ class PrefixFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'Site not found.'}) error_messages={'invalid_choice': 'Site not found.'})
vlan_group_name = forms.CharField(required=False) vlan_group_name = forms.CharField(required=False)
vlan_vid = forms.IntegerField(required=False) vlan_vid = forms.IntegerField(required=False)
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES]) status = forms.CharField()
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}) error_messages={'invalid_choice': 'Invalid role.'})
class Meta: class Meta:
model = Prefix model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool', fields = [
'description'] 'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool',
'description',
]
def clean(self): def clean(self):
@@ -214,7 +215,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:
@@ -240,12 +240,12 @@ class PrefixFromCSVForm(forms.ModelForm):
except VLAN.MultipleObjectsReturned: except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
def save(self, *args, **kwargs): def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES}
# Assign Prefix status by name try:
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
return super(PrefixFromCSVForm, self).save(*args, **kwargs) raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
class PrefixImportForm(BootstrapMixin, BulkImportForm): class PrefixImportForm(BootstrapMixin, BulkImportForm):
@@ -310,85 +310,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 +436,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 +474,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):
@@ -484,7 +494,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'VRF not found.'}) error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}) error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES]) status = forms.CharField()
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'}) error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False) interface_name = forms.CharField(required=False)
@@ -492,7 +502,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description'] fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
def clean(self): def clean(self):
@@ -515,10 +525,14 @@ class IPAddressFromCSVForm(forms.ModelForm):
if is_primary and not device: if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP") self.add_error('is_primary', "No device specified; cannot set as primary IP")
def save(self, *args, **kwargs): def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
# Assign status by name def save(self, *args, **kwargs):
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
# Set interface # Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']: if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
@@ -602,14 +616,27 @@ 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(
queryset=Site.objects.all(),
required=False,
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}}', 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 +645,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):
@@ -645,7 +657,7 @@ class VLANFromCSVForm(forms.ModelForm):
Tenant.objects.all(), to_field_name='name', required=False, Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'} error_messages={'invalid_choice': 'Tenant not found.'}
) )
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES]) status = forms.CharField()
role = forms.ModelChoiceField( role = forms.ModelChoiceField(
queryset=Role.objects.all(), required=False, to_field_name='name', queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'} error_messages={'invalid_choice': 'Invalid role.'}
@@ -653,7 +665,7 @@ class VLANFromCSVForm(forms.ModelForm):
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status_name', 'role', 'description'] fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
def clean(self): def clean(self):
@@ -663,10 +675,17 @@ 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))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
vlan = super(VLANFromCSVForm, self).save(commit=False) vlan = super(VLANFromCSVForm, self).save(commit=False)
@@ -675,9 +694,6 @@ class VLANFromCSVForm(forms.ModelForm):
if self.cleaned_data['group_name']: if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name']) vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
# Assign VLAN status by name
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'): if kwargs.get('commit'):
vlan.save() vlan.save()
return vlan return vlan
@@ -697,7 +713,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

@@ -70,9 +70,18 @@ IPADDRESS_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a> <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %} {% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a> <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %} {% else %}
{{ record.0 }} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %}
"""
IPADDRESS_DEVICE = """
{% if record.interface %}
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
({{ record.interface.name }})
{% else %}
&mdash;
{% endif %} {% endif %}
""" """
@@ -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,
@@ -528,6 +525,7 @@ def prefix_ipaddresses(request, pk):
'prefix': prefix, 'prefix': prefix,
'ip_table': ip_table, 'ip_table': ip_table,
'permissions': permissions, 'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
}) })
@@ -536,7 +534,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 +585,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.3'
# Import local configuration # Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

View File

@@ -1,3 +1,4 @@
from collections import OrderedDict
import sys import sys
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -27,91 +28,91 @@ from .forms import SearchForm
SEARCH_MAX_RESULTS = 15 SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = { SEARCH_TYPES = OrderedDict((
# Circuits # Circuits
'provider': { ('provider', {
'queryset': Provider.objects.all(), 'queryset': Provider.objects.all(),
'filter': ProviderFilter, 'filter': ProviderFilter,
'table': ProviderSearchTable, 'table': ProviderSearchTable,
'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',
}, }),
# DCIM # DCIM
'site': { ('site', {
'queryset': Site.objects.select_related('region', 'tenant'), 'queryset': Site.objects.select_related('region', 'tenant'),
'filter': SiteFilter, 'filter': SiteFilter,
'table': SiteSearchTable, 'table': SiteSearchTable,
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}, }),
'rack': { ('rack', {
'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': RackFilter, 'filter': RackFilter,
'table': RackSearchTable, 'table': RackSearchTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}, }),
'devicetype': { ('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer'), 'queryset': DeviceType.objects.select_related('manufacturer'),
'filter': DeviceTypeFilter, 'filter': DeviceTypeFilter,
'table': DeviceTypeSearchTable, 'table': DeviceTypeSearchTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
}, }),
'device': { ('device', {
'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'), 'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
'filter': DeviceFilter, 'filter': DeviceFilter,
'table': DeviceSearchTable, 'table': DeviceSearchTable,
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}, }),
# IPAM # IPAM
'vrf': { ('vrf', {
'queryset': VRF.objects.select_related('tenant'), 'queryset': VRF.objects.select_related('tenant'),
'filter': VRFFilter, 'filter': VRFFilter,
'table': VRFSearchTable, 'table': VRFSearchTable,
'url': 'ipam:vrf_list', 'url': 'ipam:vrf_list',
}, }),
'aggregate': { ('aggregate', {
'queryset': Aggregate.objects.select_related('rir'), 'queryset': Aggregate.objects.select_related('rir'),
'filter': AggregateFilter, 'filter': AggregateFilter,
'table': AggregateSearchTable, 'table': AggregateSearchTable,
'url': 'ipam:aggregate_list', 'url': 'ipam:aggregate_list',
}, }),
'prefix': { ('prefix', {
'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filter': PrefixFilter, 'filter': PrefixFilter,
'table': PrefixSearchTable, 'table': PrefixSearchTable,
'url': 'ipam:prefix_list', 'url': 'ipam:prefix_list',
}, }),
'ipaddress': { ('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
'filter': IPAddressFilter, 'filter': IPAddressFilter,
'table': IPAddressSearchTable, 'table': IPAddressSearchTable,
'url': 'ipam:ipaddress_list', 'url': 'ipam:ipaddress_list',
}, }),
'vlan': { ('vlan', {
'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': VLANFilter, 'filter': VLANFilter,
'table': VLANSearchTable, 'table': VLANSearchTable,
'url': 'ipam:vlan_list', 'url': 'ipam:vlan_list',
}, }),
# Secrets # Secrets
'secret': { ('secret', {
'queryset': Secret.objects.select_related('role', 'device'), 'queryset': Secret.objects.select_related('role', 'device'),
'filter': SecretFilter, 'filter': SecretFilter,
'table': SecretSearchTable, 'table': SecretSearchTable,
'url': 'secrets:secret_list', 'url': 'secrets:secret_list',
}, }),
# Tenancy # Tenancy
'tenant': { ('tenant', {
'queryset': Tenant.objects.select_related('group'), 'queryset': Tenant.objects.select_related('group'),
'filter': TenantFilter, 'filter': TenantFilter,
'table': TenantSearchTable, 'table': TenantSearchTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}, }),
} ))
def home(request): def home(request):

View File

@@ -74,6 +74,13 @@ footer p {
} }
} }
/* Hide the nav search bar on displays less than 1600px wide */
@media (max-width: 1599px) {
#navbar_search {
display: none;
}
}
/* Forms */ /* Forms */
label { label {
font-weight: normal; font-weight: normal;

View File

@@ -16,7 +16,7 @@ $(document).ready(function() {
// Adding/editing a secret // Adding/editing a secret
$('form').submit(function(event) { $('form').submit(function(event) {
$(this).find('input.requires-session-key').each(function() { $(this).find('.requires-session-key').each(function() {
if (this.value && document.cookie.indexOf('session_key') == -1) { if (this.value && document.cookie.indexOf('session_key') == -1) {
console.log('Field ' + this.value + ' requires a session key'); console.log('Field ' + this.value + ' requires a session key');
$('#privkey_modal').modal('show'); $('#privkey_modal').modal('show');

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

@@ -246,8 +246,8 @@
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" title="{{ request.user }}" role="button" aria-haspopup="true" aria-expanded="false">
{{ request.user }} <span class="caret"></span> {{ request.user|truncatechars:"30" }} <span class="caret"></span>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="{% url 'user:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li> <li><a href="{% url 'user:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li>
@@ -262,7 +262,7 @@
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li> <li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
{% endif %} {% endif %}
</ul> </ul>
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" role="search"> <form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
<div class="input-group"> <div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search"> <input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn"> <span class="input-group-btn">

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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Circuit Import{% endblock %} {% block title %}Circuit Import{% endblock %}

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Provider Import{% endblock %} {% block title %}Provider Import{% endblock %}

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Console Connections Import{% endblock %} {% block title %}Console Connections Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Console Connections{% endblock %} {% block title %}Console Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Console Connections</h1> <h1>Console Connections</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
{% render_table table 'table.html' %} {% include 'responsive_table.html' %}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static from staticfiles %} {% load static from staticfiles %}
{% load render_table from django_tables2 %}
{% load helpers %} {% load helpers %}
{% block title %}{{ device }}{% endblock %} {% block title %}{{ device }}{% endblock %}

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

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Device Import{% endblock %} {% block title %}Device Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Device Import{% endblock %} {% block title %}Device Import{% endblock %}

View File

@@ -31,24 +31,84 @@
{% 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();
rack_list.append($("<option></option>").attr("value", "0").text("None"));
$.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

@@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} {% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}

View File

@@ -1,4 +1,3 @@
{% load render_table from django_tables2 %}
{% if perms.dcim.change_devicetype %} {% if perms.dcim.change_devicetype %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
@@ -19,7 +18,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% render_table table 'table.html' %} {% include 'responsive_table.html' %}
<div class="panel-footer"> <div class="panel-footer">
{% if table.rows %} {% if table.rows %}
{% if edit_url %} {% if edit_url %}
@@ -48,6 +47,6 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>{{ title }}</strong> <strong>{{ title }}</strong>
</div> </div>
{% render_table table 'table.html' %} {% include 'responsive_table.html' %}
</div> </div>
{% endif %} {% endif %}

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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Interface Connections Import{% endblock %} {% block title %}Interface Connections Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Interface Connections{% endblock %} {% block title %}Interface Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Interface Connections</h1> <h1>Interface Connections</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
{% render_table table 'table.html' %} {% include 'responsive_table.html' %}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Power Connections Import{% endblock %} {% block title %}Power Connections Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Power Connections{% endblock %} {% block title %}Power Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Power Connections</h1> <h1>Power Connections</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
{% render_table table 'table.html' %} {% include 'responsive_table.html' %}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{{ rack.site }} - Rack {{ rack.name }}{% endblock %} {% block title %}{{ rack.site }} - Rack {{ rack.name }}{% endblock %}

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,12 +23,13 @@
{% 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 %}
</div> </div>
{% include 'paginator.html' %} <br />
{% include 'inc/paginator.html' %}
</div> </div>
{% else %} {% else %}
<div class="col-md-9"> <div class="col-md-9">

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Rack Import{% endblock %} {% block title %}Rack Import{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static from staticfiles %} {% load static from staticfiles %}
{% load render_table from django_tables2 %}
{% load helpers %} {% load helpers %}
{% block title %}{{ site }}{% endblock %} {% block title %}{{ site }}{% endblock %}

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

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Site Import{% endblock %} {% block title %}Site Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
{% include 'search_form.html' %} {% include 'search_form.html' %}

View File

@@ -1,9 +1,8 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
<h1>{% block title %}Import Completed{% endblock %}</h1> <h1>{% block title %}Import Completed{% endblock %}</h1>
{% render_table table %} {% include 'responsive_table.html' %}
<a href="{{ request.path }}" class="btn btn-primary"> <a href="{{ request.path }}" class="btn btn-primary">
<span class="fa fa-download" aria-hidden="true"></span> <span class="fa fa-download" aria-hidden="true"></span>
Import more Import more

View File

@@ -1,11 +1,11 @@
{% load helpers %} {% load helpers %}
<div class="paginator pull-right" style="margin-top: 20px"> <div class="paginator pull-right">
{% if paginator.num_pages > 1 %} {% if paginator.num_pages > 1 %}
<nav> <nav>
<ul class="pagination pull-right"> <ul class="pagination pull-right">
{% if page.has_previous %} {% if page.has_previous %}
<li><a href="{% querystring request page=page.previous_page_number %}">&laquo;</a></li> <li><a href="{% querystring request page=page.previous_page_number %}"><i class="fa fa-angle-double-left"></i></a></li>
{% endif %} {% endif %}
{% for p in page.smart_pages %} {% for p in page.smart_pages %}
{% if p %} {% if p %}
@@ -15,13 +15,14 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if page.has_next %} {% if page.has_next %}
<li><a href="{% querystring request page=page.next_page_number %}">&raquo;</a></li> <li><a href="{% querystring request page=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> {% if page %}
<div class="text-right text-muted"> <div class="text-right text-muted">
Showing {{ page.start_index }}-{{ page.end_index }} of {{ total_count }} Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}
</div> </div>
{% endif %}
</div> </div>

View File

@@ -0,0 +1,41 @@
{% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
{% if table.show_header %}
<thead>
<tr>
{% for column in table.columns %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}><a href="{% querystring page=column.order_by_alias.next %}">{{ column.header }}</a></th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
{% endif %}
<tbody>
{% for row in table.page.object_list|default:table.rows %}
<tr {{ row.attrs.as_html }}>
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% empty %}
{% if table.empty_text %}
<tr>
<td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
{% if table.has_footer %}
<tfoot>
<tr>
{% for column in table.columns %}
<td>{{ column.footer }}</td>
{% endfor %}
</tr>
</tfoot>
{% endif %}
</table>

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Aggregate: {{ aggregate }}{% endblock %} {% block title %}Aggregate: {{ aggregate }}{% endblock %}

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Aggregate Import{% endblock %} {% block title %}Aggregate Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}{{ ipaddress }}{% endblock %} {% block title %}{{ ipaddress }}{% endblock %}
@@ -133,17 +132,11 @@
{% endwith %} {% endwith %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
{% with heading='Parent Prefixes' %} {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
{% render_table parent_prefixes_table 'panel_table.html' %}
{% endwith %}
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
{% with heading='Duplicate IP Addresses' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% render_table duplicate_ips_table 'panel_table.html' %}
{% endwith %}
{% endif %} {% endif %}
{% with heading='Related IP Addresses' %} {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
{% render_table related_ips_table 'panel_table.html' %}
{% endwith %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}IP Address Import{% endblock %} {% block title %}IP Address Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}{{ prefix }}{% endblock %} {% block title %}{{ prefix }}{% endblock %}
@@ -134,13 +133,9 @@
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
{% if duplicate_prefix_table.rows %} {% if duplicate_prefix_table.rows %}
{% with heading='Duplicate Prefixes' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% render_table duplicate_prefix_table 'panel_table.html' %}
{% endwith %}
{% endif %} {% endif %}
{% with heading='Parent Prefixes' %} {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
{% render_table parent_prefix_table 'panel_table.html' %}
{% endwith %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">

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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Prefix Import{% endblock %} {% block title %}Prefix Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}{{ prefix }}{% endblock %} {% block title %}{{ prefix }}{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}VLAN {{ vlan.display_name }}{% endblock %} {% block title %}VLAN {{ vlan.display_name }}{% endblock %}
@@ -136,7 +135,7 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Prefixes</strong> <strong>Prefixes</strong>
</div> </div>
{% render_table prefix_table %} {% include 'responsive_table.html' with table=prefix_table %}
{% if perms.ipam.add_prefix %} {% if perms.ipam.add_prefix %}
<div class="panel-footer text-right"> <div class="panel-footer text-right">
<a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs"> <a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs">

View File

@@ -5,16 +5,22 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>VLAN</strong></div> <div class="panel-heading"><strong>VLAN</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.site %}
{% 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.site %}
{% render_field form.group %}
{% 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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}VLAN Import{% endblock %} {% block title %}VLAN Import{% endblock %}
@@ -17,7 +15,7 @@
<tbody> <tbody>
<tr> <tr>
<td>Site</td> <td>Site</td>
<td>Name of assigned site</td> <td>Name of assigned site (optional)</td>
<td>LAS2</td> <td>LAS2</td>
</tr> </tr>
<tr> <tr>

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}VRF {{ vrf }}{% endblock %} {% block title %}VRF {{ vrf }}{% endblock %}
@@ -92,7 +91,7 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Prefixes</strong> <strong>Prefixes</strong>
</div> </div>
{% render_table prefix_table %} {% include 'responsive_table.html' with table=prefix_table %}
</div> </div>
</div> </div>
</div> </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

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}VRF Import{% endblock %} {% block title %}VRF Import{% endblock %}

View File

@@ -1,10 +1,5 @@
{% extends 'django_tables2/table.html' %} {% load render_table from django_tables2 %}
{% load django_tables2 %}
{% load i18n %}
{# Wraps a table inside a Bootstrap panel and includes custom pagination rendering #}
{% block table %}
<div class="panel panel-{{ panel_class|default:'default' }}"> <div class="panel panel-{{ panel_class|default:'default' }}">
{% if heading %} {% if heading %}
<div class="panel-heading"> <div class="panel-heading">
@@ -12,15 +7,14 @@
</div> </div>
{% endif %} {% endif %}
{% if table.rows %} {% if table.rows %}
{{ block.super }} {% render_table table 'inc/table.html' %}
{% else %} {% else %}
<div class="panel-body text-muted">None</div> <div class="panel-body text-muted">None</div>
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block pagination %} {% if table.rows and not hide_paginator %}
{% if not hide_paginator %} {% with paginator=table.paginator page=table.page %}
{% include 'table_paginator.html' %} {% include 'inc/paginator.html' %}
{% endif %} {% endwith %}
{% endblock pagination %} {% endif %}

View File

@@ -0,0 +1,8 @@
{% load render_table from django_tables2 %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% with paginator=table.paginator page=table.page %}
{% include 'inc/paginator.html' %}
{% endwith %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static from staticfiles %} {% load static from staticfiles %}
{% load render_table from django_tables2 %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Secret Import{% endblock %} {% block title %}Secret Import{% endblock %}

View File

@@ -1,10 +0,0 @@
{% extends 'django_tables2/bootstrap-responsive.html' %}
{% load django_tables2 %}
{# Extends the stock django_tables2 template to provide custom formatting of the pagination controls #}
{% block pagination %}
{% if not hide_paginator %}
{% include 'table_paginator.html' %}
{% endif %}
{% endblock pagination %}

View File

@@ -1,34 +0,0 @@
{% load django_tables2 %}
{# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #}
<div class="paginator pull-right">
{% if table.paginator.num_pages > 1 %}
<nav>
<ul class="pagination pull-right">
{% if table.page.has_previous %}
<li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
{% endif %}
{% for p in table.page.smart_pages %}
{% if p %}
<li{% ifequal table.page.number p %} class="active"{% endifequal %}><a href="{% querystring table.prefixed_page_field=p %}">{{ p }}</a></li>
{% else %}
<li class="disabled"><span>&hellip;</span></li>
{% endif %}
{% endfor %}
{% if table.page.has_next %}
<li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
<div class="clearfix"></div>
<div class="text-right text-muted">
Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
{% if total == 1 %}
{{ table.data.verbose_name }}
{% else %}
{{ table.data.verbose_name_plural }}
{% endif %}
</div>
</div>

View File

@@ -1,6 +1,4 @@
{% extends 'utilities/obj_import.html' %} {% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Tenant Import{% endblock %} {% block title %}Tenant Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %} {% load form_helpers %}
{% block content %} {% block content %}

View File

@@ -1,4 +1,3 @@
{% load render_table from django_tables2 %}
{% load helpers %} {% load helpers %}
{% if permissions.change or permissions.delete %} {% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal"> <form method="post" class="form form-horizontal">
@@ -15,12 +14,12 @@
</div> </div>
<div class="pull-right"> <div class="pull-right">
{% if bulk_edit_url and permissions.change %} {% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled"> <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit All <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit All
</button> </button>
{% endif %} {% endif %}
{% if bulk_delete_url and permissions.delete %} {% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled"> <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete All <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete All
</button> </button>
{% endif %} {% endif %}
@@ -28,7 +27,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% render_table table table_template|default:'table.html' %} {% include table_template|default:'responsive_table.html' %}
{% block extra_actions %}{% endblock %} {% block extra_actions %}{% endblock %}
{% if bulk_edit_url and permissions.change %} {% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm"> <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
@@ -42,6 +41,6 @@
{% endif %} {% endif %}
</form> </form>
{% else %} {% else %}
{% render_table table table_template|default:'table.html' %} {% include table_template|default:'responsive_table.html' %}
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></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,9 +216,12 @@ 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)
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect('user:token_list') return redirect('user:token_list')

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,30 @@ 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 and self.data.get(parent_field):
filters_dict[db_field] = self.data[parent_field]
elif self.initial.get(parent_field):
filters_dict[db_field] = self.initial[parent_field]
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

@@ -4,7 +4,6 @@ from django_tables2 import RequestConfig
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
@@ -290,66 +289,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()
pass
if not form.errors: # 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) msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg) UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
return redirect(self.default_return_url) return redirect(self.default_return_url)
except IntegrityError:
pass
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),
}) })