diff --git a/CHANGELOG.md b/CHANGELOG.md index 0facf5f62..34d4b7f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ## Enhancements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f27317deb..a688be9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index dc673221d..617268c69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md index 7b37276e8..b20a6b424 100644 --- a/docs/additional-features/graphs.md +++ b/docs/additional-features/graphs.md @@ -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`. diff --git a/mkdocs.yml b/mkdocs.yml index 99f77d06c..932536d66 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,17 +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' - - 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' + - 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' diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 100c6334f..a64dfc11e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -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', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6c145e9fa..c2818e267 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2228,6 +2228,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 @@ -2235,9 +2239,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( @@ -2342,7 +2352,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 @@ -2452,7 +2462,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 @@ -2564,7 +2574,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 diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 88d2aee1d..5ffd15842 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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): """ @@ -2817,6 +2828,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( @@ -2858,20 +2883,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") diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 250173d79..ae42c507e 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -74,7 +74,8 @@ RACKROLE_ACTIONS = """ RACK_ROLE = """ {% if record.role %} - + {% load helpers %} + {% else %} — {% endif %} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2135aba66..2b5bed283 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -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): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index abf0d8cf5..8cbddc860 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -235,8 +235,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) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index d136d3271..2545b34d5 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -107,6 +107,7 @@ EXPORTTEMPLATE_MODELS = [ 'dcim.device', 'dcim.devicetype', 'dcim.interface', + 'dcim.inventoryitem', 'dcim.manufacturer', 'dcim.powerpanel', 'dcim.powerport', diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index b31271230..66a7fe637 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -230,7 +230,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(): diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d764e3d31..04caf3376 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -435,14 +435,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 queryset.' + ) mime_type = models.CharField( max_length=50, - blank=True + blank=True, + verbose_name='MIME type', + help_text='Defaults to text/plain' ) file_extension = models.CharField( max_length=15, - blank=True + blank=True, + help_text='Extension to append to the rendered filename' ) class Meta: diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 5e867e1d6..3304c27dd 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -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(), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2fbdc6ac7..0175d89f2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.6.5-dev' +VERSION = '2.6.6-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 24cf9a9ea..d37a2106e 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -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 { diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 287c19465..ae3501cae 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -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; diff --git a/netbox/scripts/examples.py b/netbox/scripts/examples.py deleted file mode 100644 index b2adf8da4..000000000 --- a/netbox/scripts/examples.py +++ /dev/null @@ -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) diff --git a/netbox/scripts/myscripts.py b/netbox/scripts/myscripts.py deleted file mode 100644 index f3542c368..000000000 --- a/netbox/scripts/myscripts.py +++ /dev/null @@ -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" diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 60a70c36c..2a347e6e6 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -135,6 +135,10 @@ {{ rack.devices.count }} + + Utilization + {% utilization_graph rack.get_utilization %} +
diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/interface_edit.html index 7977f9fed..437b960c9 100644 --- a/netbox/templates/virtualization/interface_edit.html +++ b/netbox/templates/virtualization/interface_edit.html @@ -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 %}
- {% if obj.mode %} -
-
802.1Q VLANs
- {% include 'dcim/inc/interface_vlans_table.html' %} - -
- {% endif %} {% endblock %} {% block buttons %} @@ -36,19 +27,4 @@ {% endif %} Cancel -{% endblock %} - -{% block javascript %} - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/netbox/utilities/templates/widgets/colorselect_option.html b/netbox/utilities/templates/widgets/colorselect_option.html index a0e488f18..db40173de 100644 --- a/netbox/utilities/templates/widgets/colorselect_option.html +++ b/netbox/utilities/templates/widgets/colorselect_option.html @@ -1 +1,2 @@ - +{% load helpers %} + diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 55672514e..d3fc4b83b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -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 diff --git a/upgrade.sh b/upgrade.sh index 793e72cda..d1157e3ca 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -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)..."