Merge branch 'develop' into 451-devicetype-import

This commit is contained in:
Jeremy Stretch 2019-09-25 13:44:48 -04:00
commit 93154abb31
25 changed files with 325 additions and 316 deletions

View File

@ -1,3 +1,25 @@
v2.6.5 (2019-09-25)
## Enhancements
* [#3297](https://github.com/netbox-community/netbox/issues/3297) - Include reserved units when calculating rack utilization
* [#3347](https://github.com/netbox-community/netbox/issues/3347) - Extend upgrade script to automatically remove stale content types
* [#3352](https://github.com/netbox-community/netbox/issues/3352) - Enable filtering changelog API by `changed_object_id`
* [#3515](https://github.com/netbox-community/netbox/issues/3515) - Enable export templates for inventory items
* [#3524](https://github.com/netbox-community/netbox/issues/3524) - Enable bulk editing of power outlet/power port associations
* [#3529](https://github.com/netbox-community/netbox/issues/3529) - Enable filtering circuits list by region
## Bug Fixes
* [#3435](https://github.com/netbox-community/netbox/issues/3435) - Change IP/prefix CSV export to reference VRF name instead of RD
* [#3464](https://github.com/netbox-community/netbox/issues/3464) - Fix foreground text color on color picker fields
* [#3519](https://github.com/netbox-community/netbox/issues/3519) - Prevent cables from being terminated to virtual/wireless interfaces via API
* [#3521](https://github.com/netbox-community/netbox/issues/3521) - Fix error in `parseURL` related to variables in API URL
* [#3531](https://github.com/netbox-community/netbox/issues/3531) - Fixed rack role foreground color
* [#3534](https://github.com/netbox-community/netbox/issues/3534) - Added blank option for untagged VLANs
* [#3540](https://github.com/netbox-community/netbox/issues/3540) - Fixed virtual machine interface edit with new inline vlan edit fields
* [#3543](https://github.com/netbox-community/netbox/issues/3543) - Added inline VLAN editing to virtual machine interfaces
v2.6.4 (2019-09-19) v2.6.4 (2019-09-19)
## Enhancements ## Enhancements

View File

@ -117,3 +117,25 @@ Only comment on an issue if you are sharing a relevant idea or constructive
feedback. **Do not** comment on an issue just to show your support (give the feedback. **Do not** comment on an issue just to show your support (give the
top post a :+1: instead) or ask for an ETA. These comments will be deleted to top post a :+1: instead) or ask for an ETA. These comments will be deleted to
reduce noise in the discussion. reduce noise in the discussion.
## Maintainer Guidance
* Maintainers are expected to contribute at least four hours per week to the
project on average. This can be employer-sponsored or individual time, with
the understanding that all contributions are submitted under the Apache 2.0
license and that your employer may not make claim to any contributions.
Contributions include code work, issue management, and community support. All
development must be in accordance with our [development guidance](https://netbox.readthedocs.io/en/stable/development/).
* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
sync to review agenda items. This meeting provides opportunity to present and
discuss pressing topics. Meetings are held as virtual audio/video conferences.
* Official channels for communication include:
* GitHub issues/pull requests
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
* Maintainers with no substantial recorded activity in a 60-day period will be
removed from the project.

View File

@ -40,6 +40,7 @@ and run `upgrade.sh`.
* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae)) * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
# Related projects # Related projects

View File

@ -2,7 +2,7 @@
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. * **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. * **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
* **Name:** The title to display above the graph. * **Name:** The title to display above the graph.
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.

View File

@ -27,17 +27,18 @@ pages:
- Secrets: 'core-functionality/secrets.md' - Secrets: 'core-functionality/secrets.md'
- Tenancy: 'core-functionality/tenancy.md' - Tenancy: 'core-functionality/tenancy.md'
- Additional Features: - Additional Features:
- Tags: 'additional-features/tags.md' - Caching: 'additional-features/caching.md'
- Custom Fields: 'additional-features/custom-fields.md' - Change Logging: 'additional-features/change-logging.md'
- Context Data: 'additional-features/context-data.md' - Context Data: 'additional-features/context-data.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md' - Export Templates: 'additional-features/export-templates.md'
- Graphs: 'additional-features/graphs.md' - Graphs: 'additional-features/graphs.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Reports: 'additional-features/reports.md'
- Webhooks: 'additional-features/webhooks.md'
- Change Logging: 'additional-features/change-logging.md'
- Caching: 'additional-features/caching.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Webhooks: 'additional-features/webhooks.md'
- Administration: - Administration:
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from taggit.forms import TagField from taggit.forms import TagField
from dcim.models import Site from dcim.models import Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
@ -268,7 +268,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Circuit model = Circuit
field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate'] field_order = [
'q', 'type', 'provider', 'status', 'region', 'site', 'tenant_group', 'tenant', 'commit_rate',
]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
label='Search' label='Search'
@ -294,6 +296,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
region = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
)
)
site = FilterChoiceField( site = FilterChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',

View File

@ -2228,6 +2228,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
choices=add_blank_choice(POWERFEED_LEG_CHOICES), choices=add_blank_choice(POWERFEED_LEG_CHOICES),
required=False, required=False,
) )
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=100, max_length=100,
required=False required=False
@ -2235,9 +2239,15 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'feed_leg', 'description', 'feed_leg', 'power_port', 'description',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port queryset to PowerPorts which belong to the parent Device
self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent_obj)
class PowerOutletBulkRenameForm(BulkRenameForm): class PowerOutletBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
@ -2342,7 +2352,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
[(vlan.pk, vlan) for vlan in site_group_vlans] [(vlan.pk, vlan) for vlan in site_group_vlans]
)) ))
self.fields['untagged_vlan'].choices = vlan_choices self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices self.fields['tagged_vlans'].choices = vlan_choices
@ -2452,7 +2462,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
[(vlan.pk, vlan) for vlan in site_group_vlans] [(vlan.pk, vlan) for vlan in site_group_vlans]
)) ))
self.fields['untagged_vlan'].choices = vlan_choices self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices self.fields['tagged_vlans'].choices = vlan_choices
@ -2564,7 +2574,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
[(vlan.pk, vlan) for vlan in site_group_vlans] [(vlan.pk, vlan) for vlan in site_group_vlans]
)) ))
self.fields['untagged_vlan'].choices = vlan_choices self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices self.fields['tagged_vlans'].choices = vlan_choices

View File

@ -732,10 +732,21 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_utilization(self): def get_utilization(self):
""" """
Determine the utilization rate of the rack and return it as a percentage. Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
as utilized.
""" """
u_available = len(self.get_available_units()) # Determine unoccupied units
return int(float(self.u_height - u_available) / self.u_height * 100) available_units = self.get_available_units()
# Remove reserved units
for u in self.get_reserved_units():
if u in available_units:
available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units)
percentage = int(float(occupied_unit_count) / self.u_height * 100)
return percentage
def get_power_utilization(self): def get_power_utilization(self):
""" """
@ -2817,6 +2828,20 @@ class Cable(ChangeLoggedModel):
type_a = self.termination_a_type.model type_a = self.termination_a_type.model
type_b = self.termination_b_type.model type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible # Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format( raise ValidationError("Incompatible termination types: {} and {}".format(
@ -2858,20 +2883,6 @@ class Cable(ChangeLoggedModel):
self.termination_b, self.termination_b.cable_id self.termination_b, self.termination_b.cable_id
)) ))
# Virtual interfaces cannot be connected
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
if (
(
isinstance(endpoint_a, Interface) and
endpoint_a.type == IFACE_TYPE_VIRTUAL
) or
(
isinstance(endpoint_b, Interface) and
endpoint_b.type == IFACE_TYPE_VIRTUAL
)
):
raise ValidationError("Cannot connect to a virtual interface")
# Validate length and length_unit # Validate length and length_unit
if self.length is not None and self.length_unit is None: if self.length is not None and self.length_unit is None:
raise ValidationError("Must specify a unit when setting a cable length") raise ValidationError("Must specify a unit when setting a cable length")

View File

@ -74,7 +74,8 @@ RACKROLE_ACTIONS = """
RACK_ROLE = """ RACK_ROLE = """
{% if record.role %} {% if record.role %}
<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label> {% load helpers %}
<label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
{% else %} {% else %}
&mdash; &mdash;
{% endif %} {% endif %}

View File

@ -343,7 +343,7 @@ class CableTestCase(TestCase):
def test_cable_validates_compatibale_types(self): def test_cable_validates_compatibale_types(self):
""" """
The clean method should have a check to ensure only compatiable port types can be connected by a cable The clean method should have a check to ensure only compatible port types can be connected by a cable
""" """
# An interface cannot be connected to a power port # An interface cannot be connected to a power port
cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
@ -360,30 +360,39 @@ class CableTestCase(TestCase):
def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self): def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
""" """
A cable cannot connect a front port to its sorresponding rear port A cable cannot connect a front port to its corresponding rear port
""" """
cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_cable_cannot_be_connected_to_an_existing_connection(self): def test_cable_cannot_terminate_to_an_existing_connection(self):
""" """
Either side of a cable cannot be terminated when that side aready has a connection Either side of a cable cannot be terminated when that side already has a connection
""" """
# Try to create a cable with the same interface terminations # Try to create a cable with the same interface terminations
cable = Cable(termination_a=self.interface2, termination_b=self.interface1) cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_cable_cannot_connect_to_a_virtual_inteface(self): def test_cable_cannot_terminate_to_a_virtual_inteface(self):
""" """
A cable connection cannot include a virtual interface A cable cannot terminate to a virtual interface
""" """
virtual_interface = Interface(device=self.device1, name="V1", type=0) virtual_interface = Interface(device=self.device1, name="V1", type=IFACE_TYPE_VIRTUAL)
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_cable_cannot_terminate_to_a_wireless_inteface(self):
"""
A cable cannot terminate to a wireless interface
"""
wireless_interface = Interface(device=self.device1, name="W1", type=IFACE_TYPE_80211A)
cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
with self.assertRaises(ValidationError):
cable.clean()
class CablePathTestCase(TestCase): class CablePathTestCase(TestCase):

View File

@ -235,8 +235,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object', 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'object_data', 'changed_object', 'object_data',
] ]
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)

View File

@ -107,6 +107,7 @@ EXPORTTEMPLATE_MODELS = [
'dcim.device', 'dcim.device',
'dcim.devicetype', 'dcim.devicetype',
'dcim.interface', 'dcim.interface',
'dcim.inventoryitem',
'dcim.manufacturer', 'dcim.manufacturer',
'dcim.powerpanel', 'dcim.powerpanel',
'dcim.powerport', 'dcim.powerport',

View File

@ -230,7 +230,9 @@ class ObjectChangeFilter(django_filters.FilterSet):
class Meta: class Meta:
model = ObjectChange model = ObjectChange
fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr'] fields = [
'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr',
]
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -435,14 +435,19 @@ class ExportTemplate(models.Model):
choices=TEMPLATE_LANGUAGE_CHOICES, choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2 default=TEMPLATE_LANGUAGE_JINJA2
) )
template_code = models.TextField() template_code = models.TextField(
help_text='The list of objects being exported is passed as a context variable named <code>queryset</code>.'
)
mime_type = models.CharField( mime_type = models.CharField(
max_length=50, max_length=50,
blank=True blank=True,
verbose_name='MIME type',
help_text='Defaults to <code>text/plain</code>'
) )
file_extension = models.CharField( file_extension = models.CharField(
max_length=15, max_length=15,
blank=True blank=True,
help_text='Extension to append to the rendered filename'
) )
class Meta: class Meta:

View File

@ -382,7 +382,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
def to_csv(self): def to_csv(self):
return ( return (
self.prefix, self.prefix,
self.vrf.rd if self.vrf else None, self.vrf.name if self.vrf else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.site.name if self.site else None, self.site.name if self.site else None,
self.vlan.group.name if self.vlan and self.vlan.group else None, self.vlan.group.name if self.vlan and self.vlan.group else None,
@ -674,7 +674,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
return ( return (
self.address, self.address,
self.vrf.rd if self.vrf else None, self.vrf.name if self.vrf else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.get_status_display(), self.get_status_display(),
self.get_role_display(), self.get_role_display(),

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.6.5-dev' VERSION = '2.6.6-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -133,116 +133,6 @@ input[name="pk"] {
margin-top: 0; margin-top: 0;
} }
/* Color Selections */
.color-selection-aa1409 {
background-color: #aa1409;
color: #ffffff;
}
.color-selection-f44336 {
background-color: #f44336;
color: #ffffff;
}
.color-selection-e91e63 {
background-color: #e91e63;
color: #ffffff;
}
.color-selection-ffe4e1 {
background-color: #ffe4e1;
color: #000000;
}
.color-selection-ff66ff {
background-color: #ff66ff;
color: #ffffff;
}
.color-selection-9c27b0 {
background-color: #9c27b0;
color: #ffffff;
}
.color-selection-673ab7 {
background-color: #673ab7;
color: #ffffff;
}
.color-selection-3f51b5 {
background-color: #3f51b5;
color: #ffffff;
}
.color-selection-2196f3 {
background-color: #2196f3;
color: #ffffff;
}
.color-selection-03a9f4 {
background-color: #03a9f4;
color: #ffffff;
}
.color-selection-00bcd4 {
background-color: #00bcd4;
color: #ffffff;
}
.color-selection-009688 {
background-color: #009688;
color: #ffffff;
}
.color-selection-00ffff {
background-color: #00ffff;
color: #ffffff;
}
.color-selection-2f6a31 {
background-color: #2f6a31;
color: #ffffff;
}
.color-selection-4caf50 {
background-color: #4caf50;
color: #ffffff;
}
.color-selection-8bc34a {
background-color: #8bc34a;
color: #ffffff;
}
.color-selection-cddc39 {
background-color: #cddc39;
color: #000000;
}
.color-selection-ffeb3b {
background-color: #ffeb3b;
color: #000000;
}
.color-selection-ffc107 {
background-color: #ffc107;
color: #000000;
}
.color-selection-ff9800 {
background-color: #ff9800;
color: #ffffff;
}
.color-selection-ff5722 {
background-color: #ff5722;
color: #ffffff;
}
.color-selection-795548 {
background-color: #795548;
color: #ffffff;
}
.color-selection-c0c0c0 {
background-color: #c0c0c0;
color: #000000;
}
.color-selection-9e9e9e {
background-color: #9e9e9e;
color: #ffffff;
}
.color-selection-607d8b {
background-color: #607d8b;
color: #ffffff;
}
.color-selection-111111 {
background-color: #111111;
color: #ffffff;
}
.color-selection-ffffff {
background-color: #ffffff;
color: #000000;
}
/* Tables */ /* Tables */
th.pk, td.pk { th.pk, td.pk {

View File

@ -75,7 +75,7 @@ $(document).ready(function() {
var rendered_url = url; var rendered_url = url;
var filter_field; var filter_field;
while (match = filter_regex.exec(url)) { while (match = filter_regex.exec(url)) {
filter_field = $('#id_' + match[1]);untagged filter_field = $('#id_' + match[1]);
var custom_attr = $('option:selected', filter_field).attr('api-value'); var custom_attr = $('option:selected', filter_field).attr('api-value');
if (custom_attr) { if (custom_attr) {
rendered_url = rendered_url.replace(match[0], custom_attr); rendered_url = rendered_url.replace(match[0], custom_attr);
@ -91,11 +91,8 @@ $(document).ready(function() {
// Assign color picker selection classes // Assign color picker selection classes
function colorPickerClassCopy(data, container) { function colorPickerClassCopy(data, container) {
if (data.element) { if (data.element) {
// Remove any existing color-selection classes // Swap the style
$(container).attr('class', function(i, c) { $(container).attr('style', $(data.element).attr("style"));
return c.replace(/(^|\s)color-selection-\S+/g, '');
});
$(container).addClass($(data.element).attr("class"));
} }
return data.text; return data.text;
} }
@ -200,7 +197,7 @@ $(document).ready(function() {
$(element).children('option').attr('disabled', false); $(element).children('option').attr('disabled', false);
var results = data.results; var results = data.results;
results = results.reduce((results,record) => { results = results.reduce((results,record,idx) => {
record.text = record[element.getAttribute('display-field')] || record.name; record.text = record[element.getAttribute('display-field')] || record.name;
record.id = record[element.getAttribute('value-field')] || record.id; record.id = record[element.getAttribute('value-field')] || record.id;
if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) { if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
@ -225,7 +222,7 @@ $(document).ready(function() {
results['global'].children.push(record); results['global'].children.push(record);
} }
else { else {
results[record.id] = record results[idx] = record
} }
return results; return results;

View File

@ -1,66 +0,0 @@
from django.utils.text import slugify
from dcim.constants import *
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import *
class NewBranchScript(Script):
script_name = "New Branch"
script_description = "Provision a new branch site"
script_fields = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar(
description="Name of the new site"
)
switch_count = IntegerVar(
description="Number of access switches to create"
)
switch_model = ObjectVar(
description="Access switch model",
queryset=DeviceType.objects.filter(
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
)
x = BooleanVar(
description="Check me out"
)
def run(self, data):
# Create the new site
site = Site(
name=data['site_name'],
slug=slugify(data['site_name']),
status=SITE_STATUS_PLANNED
)
site.save()
self.log_success("Created new site: {}".format(site))
# Create access switches
switch_role = DeviceRole.objects.get(name='Access Switch')
for i in range(1, data['switch_count'] + 1):
switch = Device(
device_type=data['switch_model'],
name='{}-switch{}'.format(site.slug, i),
site=site,
status=DEVICE_STATUS_PLANNED,
device_role=switch_role
)
switch.save()
self.log_success("Created new switch: {}".format(switch))
# Generate a CSV table of new devices
output = [
'name,make,model'
]
for switch in Device.objects.filter(site=site):
attrs = [
switch.name,
switch.device_type.manufacturer.name,
switch.device_type.model
]
output.append(','.join(attrs))
return '\n'.join(output)

View File

@ -1,54 +0,0 @@
from dcim.models import Site
from extras.scripts import Script, BooleanVar, IntegerVar, ObjectVar, StringVar
class NoInputScript(Script):
description = "This script does not require any input"
def run(self, data):
self.log_debug("This a debug message.")
self.log_info("This an info message.")
self.log_success("This a success message.")
self.log_warning("This a warning message.")
self.log_failure("This a failure message.")
class DemoScript(Script):
name = "Script Demo"
description = "A quick demonstration of the available field types"
my_string1 = StringVar(
description="Input a string between 3 and 10 characters",
min_length=3,
max_length=10
)
my_string2 = StringVar(
description="This field enforces a regex: three letters followed by three numbers",
regex=r'[a-z]{3}\d{3}'
)
my_number = IntegerVar(
description="Pick a number between 1 and 255 (inclusive)",
min_value=1,
max_value=255
)
my_boolean = BooleanVar(
description="Use the checkbox to toggle true/false"
)
my_object = ObjectVar(
description="Select a NetBox site",
queryset=Site.objects.all()
)
def run(self, data):
self.log_info("Your string was {}".format(data['my_string1']))
self.log_info("Your second string was {}".format(data['my_string2']))
self.log_info("Your number was {}".format(data['my_number']))
if data['my_boolean']:
self.log_info("You ticked the checkbox")
else:
self.log_info("You did not tick the checkbox")
self.log_info("You chose the sites {}".format(data['my_object']))
return "Here's some output"

View File

@ -135,6 +135,10 @@
<a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a> <a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
</td> </td>
</tr> </tr>
<tr>
<td>Utilization</td>
<td>{% utilization_graph rack.get_utilization %}</td>
</tr>
</table> </table>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -11,20 +11,11 @@
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
</div> </div>
{% if obj.mode %}
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
{% include 'dcim/inc/interface_vlans_table.html' %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
<i class="glyphicon glyphicon-plus"></i> Add VLANs
</a>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block buttons %} {% block buttons %}
@ -36,19 +27,4 @@
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button> <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a> <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endblock %} {% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
$('#clear_untagged_vlan').click(function () {
$('input[name="untagged_vlan"]').prop("checked", false);
return false;
});
$('#clear_tagged_vlans').click(function () {
$('input[name="tagged_vlans"]').prop("checked", false);
return false;
});
});
</script>
{% endblock %}

View File

@ -1 +1,2 @@
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %} class="color-selection-{{ widget.value }}">{{ widget.label }}</option> {% load helpers %}
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %} {% if widget.value %}style="color: {{ widget.value|fgcolor }}; background-color: #{{ widget.value }}"{% endif %}>{{ widget.label }}</option>

View File

@ -2,11 +2,11 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from taggit.forms import TagField from taggit.forms import TagField
from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL, IFACE_MODE_CHOICES
from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from ipam.models import IPAddress from ipam.models import IPAddress, VLANGroup, VLAN
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -616,6 +616,24 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
# #
class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, forms.ModelForm):
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tags = TagField( tags = TagField(
required=False required=False
) )
@ -638,6 +656,39 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
'mode': INTERFACE_MODE_HELP_TEXT, 'mode': INTERFACE_MODE_HELP_TEXT,
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
site = getattr(self.instance.device, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
def clean(self): def clean(self):
super().clean() super().clean()
@ -681,6 +732,29 @@ class InterfaceCreateForm(ComponentForm):
max_length=100, max_length=100,
required=False required=False
) )
mode = forms.ChoiceField(
choices=add_blank_choice(IFACE_MODE_CHOICES),
required=False,
widget=StaticSelect2(),
)
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tags = TagField( tags = TagField(
required=False required=False
) )
@ -693,6 +767,36 @@ class InterfaceCreateForm(ComponentForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
site = getattr(self.parent.cluster, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
@ -713,12 +817,68 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
max_length=100, max_length=100,
required=False required=False
) )
mode = forms.ChoiceField(
choices=add_blank_choice(IFACE_MODE_CHOICES),
required=False,
widget=StaticSelect2()
)
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'mtu', 'description', 'mtu', 'description',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
if self.parent_obj.cluster is not None:
site = getattr(self.parent_obj.cluster, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
# #
# Bulk VirtualMachine component creation # Bulk VirtualMachine component creation

View File

@ -25,6 +25,11 @@ COMMAND="${PYTHON} netbox/manage.py migrate"
echo "Applying database migrations ($COMMAND)..." echo "Applying database migrations ($COMMAND)..."
eval $COMMAND eval $COMMAND
# Delete any stale content types
COMMAND="${PYTHON} netbox/manage.py remove_stale_contenttypes --no-input"
echo "Removing stale content types ($COMMAND)..."
eval $COMMAND
# Collect static files # Collect static files
COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input" COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
echo "Collecting static files ($COMMAND)..." echo "Collecting static files ($COMMAND)..."