1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into develop-2.7

This commit is contained in:
Jeremy Stretch
2019-09-25 13:44:29 -04:00
24 changed files with 326 additions and 314 deletions

View File

@ -6,6 +6,30 @@ v2.7.0 (FUTURE)
---
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)
## 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
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
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))
* [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))
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
# 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:
* **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.
* **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`.

View File

@ -27,16 +27,18 @@ pages:
- Secrets: 'core-functionality/secrets.md'
- Tenancy: 'core-functionality/tenancy.md'
- Additional Features:
- Tags: 'additional-features/tags.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Caching: 'additional-features/caching.md'
- Change Logging: 'additional-features/change-logging.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'
- Graphs: 'additional-features/graphs.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'
- Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Webhooks: 'additional-features/webhooks.md'
- Administration:
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'

View File

@ -1,7 +1,7 @@
from django import forms
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 tenancy.forms import TenancyForm
from tenancy.forms import TenancyFilterForm
@ -268,7 +268,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
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(
required=False,
label='Search'
@ -294,6 +296,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
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(
queryset=Site.objects.all(),
to_field_name='slug',

View File

@ -2106,6 +2106,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
choices=add_blank_choice(POWERFEED_LEG_CHOICES),
required=False,
)
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
)
description = forms.CharField(
max_length=100,
required=False
@ -2113,9 +2117,15 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class Meta:
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):
pk = forms.ModelMultipleChoiceField(
@ -2220,7 +2230,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
[(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
@ -2330,7 +2340,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
[(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
@ -2442,7 +2452,7 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
[(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

View File

@ -732,10 +732,21 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
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())
return int(float(self.u_height - u_available) / self.u_height * 100)
# Determine unoccupied units
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):
"""
@ -2785,6 +2796,20 @@ class Cable(ChangeLoggedModel):
type_a = self.termination_a_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
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
@ -2826,20 +2851,6 @@ class Cable(ChangeLoggedModel):
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
if self.length is not None and self.length_unit is None:
raise ValidationError("Must specify a unit when setting a cable length")

View File

@ -74,7 +74,8 @@ RACKROLE_ACTIONS = """
RACK_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 %}
&mdash;
{% endif %}

View File

@ -343,7 +343,7 @@ class CableTestCase(TestCase):
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
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):
"""
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)
with self.assertRaises(ValidationError):
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
cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
with self.assertRaises(ValidationError):
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)
with self.assertRaises(ValidationError):
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):

View File

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

View File

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

View File

@ -212,7 +212,9 @@ class ObjectChangeFilter(django_filters.FilterSet):
class Meta:
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):
if not value.strip():

View File

@ -432,14 +432,19 @@ class ExportTemplate(models.Model):
choices=TEMPLATE_LANGUAGE_CHOICES,
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(
max_length=50,
blank=True
blank=True,
verbose_name='MIME type',
help_text='Defaults to <code>text/plain</code>'
)
file_extension = models.CharField(
max_length=15,
blank=True
blank=True,
help_text='Extension to append to the rendered filename'
)
class Meta:

View File

@ -382,7 +382,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
def to_csv(self):
return (
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.site.name if self.site else None,
self.vlan.group.name if self.vlan and self.vlan.group else None,
@ -674,7 +674,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
return (
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.get_status_display(),
self.get_role_display(),

View File

@ -133,116 +133,6 @@ input[name="pk"] {
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 */
th.pk, td.pk {

View File

@ -75,7 +75,7 @@ $(document).ready(function() {
var rendered_url = url;
var filter_field;
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');
if (custom_attr) {
rendered_url = rendered_url.replace(match[0], custom_attr);
@ -91,11 +91,8 @@ $(document).ready(function() {
// Assign color picker selection classes
function colorPickerClassCopy(data, container) {
if (data.element) {
// Remove any existing color-selection classes
$(container).attr('class', function(i, c) {
return c.replace(/(^|\s)color-selection-\S+/g, '');
});
$(container).addClass($(data.element).attr("class"));
// Swap the style
$(container).attr('style', $(data.element).attr("style"));
}
return data.text;
}
@ -200,7 +197,7 @@ $(document).ready(function() {
$(element).children('option').attr('disabled', false);
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.id = record[element.getAttribute('value-field')] || record.id;
if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
@ -225,7 +222,7 @@ $(document).ready(function() {
results['global'].children.push(record);
}
else {
results[record.id] = record
results[idx] = record
}
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>
</td>
</tr>
<tr>
<td>Utilization</td>
<td>{% utilization_graph rack.get_utilization %}</td>
</tr>
</table>
</div>
<div class="panel panel-default">

View File

@ -11,20 +11,11 @@
{% render_field form.mtu %}
{% render_field form.description %}
{% render_field form.mode %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
{% render_field form.tags %}
</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 %}
{% block buttons %}
@ -36,19 +27,4 @@
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% 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 %}
{% 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 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.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
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 TenancyFilterForm
from tenancy.models import Tenant
@ -616,6 +616,24 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
#
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(
required=False
)
@ -638,6 +656,39 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
'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):
super().clean()
@ -681,6 +732,29 @@ class InterfaceCreateForm(ComponentForm):
max_length=100,
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(
required=False
)
@ -693,6 +767,36 @@ class InterfaceCreateForm(ComponentForm):
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):
pk = forms.ModelMultipleChoiceField(
@ -713,12 +817,68 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
max_length=100,
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:
nullable_fields = [
'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

View File

@ -25,6 +25,11 @@ COMMAND="${PYTHON} netbox/manage.py migrate"
echo "Applying database migrations ($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
COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input"
echo "Collecting static files ($COMMAND)..."