diff --git a/CHANGELOG.md b/CHANGELOG.md index 6560f0e68da..75dfe2dab2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +v2.4.9 (2018-12-07) + +## Enhancements + +* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors +* [#2495](https://github.com/digitalocean/netbox/issues/2495) - Enable deep-merging of config context data +* [#2597](https://github.com/digitalocean/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor + +## Bug Fixes + +* [#2400](https://github.com/digitalocean/netbox/issues/2400) - Correct representation of nested object assignment in API docs +* [#2576](https://github.com/digitalocean/netbox/issues/2576) - Correct type for count_* fields in site API representation +* [#2606](https://github.com/digitalocean/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor +* [#2611](https://github.com/digitalocean/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site +* [#2613](https://github.com/digitalocean/netbox/issues/2613) - Decrease live search minimum characters to three +* [#2615](https://github.com/digitalocean/netbox/issues/2615) - Tweak live search widget to use brief format for API requests +* [#2623](https://github.com/digitalocean/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks +* [#2634](https://github.com/digitalocean/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view + +--- + v2.4.8 (2018-11-20) ## Enhancements diff --git a/docs/additional-features/netbox-shell.md b/docs/administration/netbox-shell.md similarity index 100% rename from docs/additional-features/netbox-shell.md rename to docs/administration/netbox-shell.md diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 190fad5e032..1fde8067bdc 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -44,7 +44,11 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th Extend the model's API serializer in `.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model. -### 6. Add field to forms +### 6. Add choices to API view + +If the new field has static choices, add it to the `FieldChoicesViewSet` for the app. + +### 7. Add field to forms Extend any forms to include the new field as appropriate. Common forms include: @@ -53,18 +57,18 @@ Extend any forms to include the new field as appropriate. Common forms include: * **CSV import** - The form used when bulk importing objects in CSV format * **Filter** - Displays the options available for filtering a list of objects (both UI and API) -### 7. Extend object filter set +### 8. Extend object filter set If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. -### 8. Add column to object table +### 9. Add column to object table If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column. -### 9. Update the UI templates +### 10. Update the UI templates Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. -### 10. Adjust API and model tests +### 11. Adjust API and model tests Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 18dadd2d22f..138d0e12d4e 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -28,6 +28,19 @@ To invoke `pycodestyle` manually, run: pycodestyle --ignore=W504,E501 netbox/ ``` +## Introducing New Dependencies + +The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. + +If there's a strong case for introducing a new depdency, it must meet the following criteria: + +* Its complete source code must be published and freely accessible without registration. +* Its license must be conducive to inclusion in an open source project. +* It must be actively maintained, with no longer than one year between releases. +* It must be available via the [Python Package Index](https://pypi.org/) (PyPI). + +When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts. + ## General Guidance * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index ad4556383bc..06c8a3d5c21 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -246,13 +246,13 @@ At this point, NetBox should be able to run. We can verify this by starting a de Performing system checks... System check identified no issues (0 silenced). -June 17, 2016 - 16:17:36 -Django version 1.9.7, using settings 'netbox.settings' +November 28, 2018 - 09:33:45 +Django version 2.0.9, using settings 'netbox.settings' Starting development server at http://0.0.0.0:8000/ Quit the server with CONTROL-C. ``` -Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** +Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** !!! warning If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected. diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index b2efadea194..1d5ceb977dd 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -36,3 +36,9 @@ If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight # pip3 install django-auth-ldap ``` + +If using Webhooks, install the `django-rq` package: + +```no-highlight +# pip3 install django-rq +``` diff --git a/mkdocs.yml b/mkdocs.yml index cd718cc7639..a0185e56e10 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ pages: - Change Logging: 'additional-features/change-logging.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' + - NetBox Shell: 'administration/netbox-shell.md' - API: - Overview: 'api/overview.md' - Authentication: 'api/authentication.md' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b0a1628de92..94d2b07a8ee 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -56,6 +56,11 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) tags = TagListSerializerField(required=False) + count_prefixes = serializers.IntegerField(read_only=True) + count_vlans = serializers.IntegerField(read_only=True) + count_racks = serializers.IntegerField(read_only=True) + count_devices = serializers.IntegerField(read_only=True) + count_circuits = serializers.IntegerField(read_only=True) class Meta: model = Site diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index c4125953364..d4182539076 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -76,12 +76,21 @@ IFACE_FF_80211G = 2610 IFACE_FF_80211N = 2620 IFACE_FF_80211AC = 2630 IFACE_FF_80211AD = 2640 +# SONET +IFACE_FF_SONET_OC3 = 6100 +IFACE_FF_SONET_OC12 = 6200 +IFACE_FF_SONET_OC48 = 6300 +IFACE_FF_SONET_OC192 = 6400 +IFACE_FF_SONET_OC768 = 6500 +IFACE_FF_SONET_OC1920 = 6600 +IFACE_FF_SONET_OC3840 = 6700 # Fibrechannel IFACE_FF_1GFC_SFP = 3010 IFACE_FF_2GFC_SFP = 3020 IFACE_FF_4GFC_SFP = 3040 IFACE_FF_8GFC_SFP_PLUS = 3080 IFACE_FF_16GFC_SFP_PLUS = 3160 +IFACE_FF_32GFC_SFP28 = 3320 # Serial IFACE_FF_T1 = 4000 IFACE_FF_E1 = 4010 @@ -146,6 +155,18 @@ IFACE_FF_CHOICES = [ [IFACE_FF_80211AD, 'IEEE 802.11ad'], ] ], + [ + 'SONET', + [ + [IFACE_FF_SONET_OC3, 'OC-3/STM-1'], + [IFACE_FF_SONET_OC12, 'OC-12/STM-4'], + [IFACE_FF_SONET_OC48, 'OC-48/STM-16'], + [IFACE_FF_SONET_OC192, 'OC-192/STM-64'], + [IFACE_FF_SONET_OC768, 'OC-768/STM-256'], + [IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'], + [IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'], + ] + ], [ 'FibreChannel', [ @@ -154,6 +175,7 @@ IFACE_FF_CHOICES = [ [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], + [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'], ] ], [ diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 8b40ca7b7c3..a8fb279543b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -13,7 +13,7 @@ from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilte from virtualization.models import Cluster from .constants import ( DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES, - WIRELESS_IFACE_TYPES, + WIRELESS_IFACE_TYPES, IFACE_FF_CHOICES, ) from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -652,10 +652,14 @@ class InterfaceFilter(django_filters.FilterSet): method='filter_vlan', label='Assigned VID' ) + form_factor = django_filters.MultipleChoiceFilter( + choices=IFACE_FF_CHOICES, + null_value=None + ) class Meta: model = Interface - fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + fields = ['name', 'enabled', 'mtu', 'mgmt_only'] def filter_device(self, queryset, name, value): try: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 46e03921115..4a75ac3868c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -20,7 +20,7 @@ from utilities.forms import ( ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, ) -from virtualization.models import Cluster +from virtualization.models import Cluster, ClusterGroup from .constants import ( CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, @@ -820,6 +820,23 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): display_field='model' ) ) + cluster_group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'cluster', 'nullable': 'true'} + ) + ) + cluster = ChainedModelChoiceField( + queryset=Cluster.objects.all(), + chains=( + ('group', 'cluster_group'), + ), + required=False, + widget=APISelect( + api_url='/api/virtualization/clusters/?group_id={{cluster_group}}', + ) + ) comments = CommentField() tags = TagField(required=False) local_context_data = JSONField(required=False) @@ -828,8 +845,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags', - 'local_context_data' + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', + 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", diff --git a/netbox/dcim/migrations/0062_interface_mtu.py b/netbox/dcim/migrations/0062_interface_mtu.py index 592f11bb79d..d1ae9252096 100644 --- a/netbox/dcim/migrations/0062_interface_mtu.py +++ b/netbox/dcim/migrations/0062_interface_mtu.py @@ -19,11 +19,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='interface', name='form_factor', - field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), ), migrations.AlterField( model_name='interfacetemplate', name='form_factor', - field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), ), ] diff --git a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py index 0ac826ba4bf..1021c20c51b 100644 --- a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -2,9 +2,6 @@ # Generated by Django 1.11.14 on 2018-07-31 02:19 from __future__ import unicode_literals -import re -from distutils.version import StrictVersion - from django.conf import settings import django.contrib.postgres.fields.jsonb from django.db import connection, migrations, models @@ -19,13 +16,14 @@ def verify_postgresql_version(apps, schema_editor): """ Verify that PostgreSQL is version 9.4 or higher. """ + # https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION + DB_MINIMUM_VERSION = 90400 # 9.4.0 + try: - with connection.cursor() as cursor: - cursor.execute("SELECT VERSION()") - row = cursor.fetchone() - pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) - if StrictVersion(pg_version) < StrictVersion('9.4.0'): - raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) + pg_version = connection.pg_version + + if pg_version < DB_MINIMUM_VERSION: + raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version)) # Skip if the database is missing (e.g. for CI testing) or misconfigured. except OperationalError: diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index fbfde2cbae6..9c26f50ba35 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-26 21:25 from __future__ import unicode_literals -from distutils.version import StrictVersion -import re from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -15,13 +13,14 @@ def verify_postgresql_version(apps, schema_editor): """ Verify that PostgreSQL is version 9.4 or higher. """ + # https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION + DB_MINIMUM_VERSION = 90400 # 9.4.0 + try: - with connection.cursor() as cursor: - cursor.execute("SELECT VERSION()") - row = cursor.fetchone() - pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) - if StrictVersion(pg_version) < StrictVersion('9.4.0'): - raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) + pg_version = connection.pg_version + + if pg_version < DB_MINIMUM_VERSION: + raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version)) # Skip if the database is missing (e.g. for CI testing) or misconfigured. except OperationalError: diff --git a/netbox/extras/models.py b/netbox/extras/models.py index de3edca9b38..1605df6dfd5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -18,7 +18,7 @@ from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe from dcim.constants import CONNECTION_STATUS_CONNECTED -from utilities.utils import foreground_color +from utilities.utils import deepmerge, foreground_color from .constants import * from .querysets import ConfigContextQuerySet @@ -727,11 +727,11 @@ class ConfigContextModel(models.Model): # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs data = OrderedDict() for context in ConfigContext.objects.get_for_object(self): - data.update(context.data) + data = deepmerge(data, context.data) - # If the object has local config context data defined, that data overwrites all rendered data + # If the object has local config context data defined, merge it last if self.local_context_data is not None: - data.update(self.local_context_data) + data = deepmerge(data, self.local_context_data) return data diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index a0c927b6484..35ec56febce 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -45,7 +45,7 @@ def enqueue_webhooks(instance, action): "extras.webhooks_worker.process_webhook", webhook, serializer.data, - instance.__class__, + instance._meta.model_name, action, str(datetime.datetime.now()) ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 2122d115471..30f86f311ef 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -10,14 +10,14 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ @job('default') -def process_webhook(webhook, data, model_class, event, timestamp): +def process_webhook(webhook, data, model_name, event, timestamp): """ Make a POST request to the defined Webhook """ payload = { 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), 'timestamp': timestamp, - 'model': model_class._meta.model_name, + 'model': model_name, 'data': data } headers = { diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 42451c9a25d..be34f2be358 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.8' +VERSION = '2.4.9' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -275,9 +275,12 @@ RQ_QUEUES = { # drf_yasg settings for Swagger SWAGGER_SETTINGS = { + 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', 'DEFAULT_FIELD_INSPECTORS': [ 'utilities.custom_inspectors.NullableBooleanFieldInspector', 'utilities.custom_inspectors.CustomChoiceFieldInspector', + 'utilities.custom_inspectors.TagListFieldInspector', + 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', 'drf_yasg.inspectors.CamelCaseJSONFilter', 'drf_yasg.inspectors.ReferencingSerializerInspector', 'drf_yasg.inspectors.RelatedFieldInspector', diff --git a/netbox/project-static/js/livesearch.js b/netbox/project-static/js/livesearch.js index e00aefbafc7..2d5afe70085 100644 --- a/netbox/project-static/js/livesearch.js +++ b/netbox/project-static/js/livesearch.js @@ -24,7 +24,7 @@ $(document).ready(function() { source: function(request, response) { $.ajax({ type: 'GET', - url: search_field.attr('data-source'), + url: search_field.attr('data-source') + '?brief=1', data: search_key + '=' + request.term, success: function(data) { var choices = []; @@ -49,7 +49,7 @@ $(document).ready(function() { // Disable parent selection fields // $('select[filter-for="' + real_field.attr('name') + '"]').val(''); }, - minLength: 4, + minLength: 3, delay: 500 }); diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 27ebb052dca..2a9da7b15a9 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -54,7 +54,7 @@

- Docs · + Docs · API · Code · Help diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 23b2b404eaf..1486c1ad598 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -62,6 +62,13 @@ {% endif %}

+
+
Virtualization
+
+ {% render_field form.cluster_group %} + {% render_field form.cluster %} +
+
Tenancy
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 35931b49fcc..b109c399b9f 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,7 +55,10 @@ Model Name - {{ devicetype.model }} + + {{ devicetype.model }}
+ {{ devicetype.slug }} + Part Number diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 46fe01c8deb..e7beeb9ba3a 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -27,13 +27,13 @@ {% ifequal u.device.face face_id %} - {{ u.device.name|default:u.device.device_role }} + {{ u.device }} {% if u.device.devicebay_count %} ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }}) {% endif %} {% else %} - {{ u.device.name|default:u.device.device_role }} + {{ u.device }} {% endifequal %} {% else %} diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index ca6e08fc1c3..5975788bcbd 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,9 +1,52 @@ from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema from rest_framework.fields import ChoiceField +from rest_framework.relations import ManyRelatedField +from taggit_serializer.serializers import TagListSerializerField from extras.api.customfields import CustomFieldsSerializer -from utilities.api import ChoiceField +from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer + + +class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): + def get_request_serializer(self): + serializer = super().get_request_serializer() + + if serializer is not None and self.method in self.implicit_body_methods: + properties = {} + for child_name, child in serializer.fields.items(): + if isinstance(child, (ChoiceField, WritableNestedSerializer)): + properties[child_name] = None + elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): + properties[child_name] = None + + if properties: + writable_class = type('Writable' + type(serializer).__name__, (type(serializer),), properties) + serializer = writable_class() + + return serializer + + +class SerializedPKRelatedFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + if isinstance(field, SerializedPKRelatedField): + return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references) + + return NotHandled + + +class TagListFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + if isinstance(field, TagListSerializerField): + child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references) + return SwaggerType( + type=openapi.TYPE_ARRAY, + items=child_schema, + ) + + return NotHandled class CustomChoiceFieldInspector(FieldInspector): diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py new file mode 100644 index 00000000000..4e0fec1ba65 --- /dev/null +++ b/netbox/utilities/tests/test_utils.py @@ -0,0 +1,89 @@ +from django.test import TestCase + +from utilities.utils import deepmerge + + +class DeepMergeTest(TestCase): + """ + Validate the behavior of the deepmerge() utility. + """ + + def setUp(self): + return + + def test_deepmerge(self): + + dict1 = { + 'active': True, + 'foo': 123, + 'fruits': { + 'orange': 1, + 'apple': 2, + 'pear': 3, + }, + 'vegetables': None, + 'dairy': { + 'milk': 1, + 'cheese': 2, + }, + 'deepnesting': { + 'foo': { + 'a': 10, + 'b': 20, + 'c': 30, + }, + }, + } + + dict2 = { + 'active': False, + 'bar': 456, + 'fruits': { + 'banana': 4, + 'grape': 5, + }, + 'vegetables': { + 'celery': 1, + 'carrots': 2, + 'corn': 3, + }, + 'dairy': None, + 'deepnesting': { + 'foo': { + 'a': 100, + 'd': 40, + }, + }, + } + + merged = { + 'active': False, + 'foo': 123, + 'bar': 456, + 'fruits': { + 'orange': 1, + 'apple': 2, + 'pear': 3, + 'banana': 4, + 'grape': 5, + }, + 'vegetables': { + 'celery': 1, + 'carrots': 2, + 'corn': 3, + }, + 'dairy': None, + 'deepnesting': { + 'foo': { + 'a': 100, + 'b': 20, + 'c': 30, + 'd': 40, + }, + }, + } + + self.assertEqual( + deepmerge(dict1, dict2), + merged + ) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 14c29d21128..642242d30a0 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from collections import OrderedDict import datetime import json import six @@ -109,3 +110,16 @@ def serialize_object(obj, extra=None): data.update(extra) return data + + +def deepmerge(original, new): + """ + Deep merge two dictionaries (new into original) and return a new dict + """ + merged = OrderedDict(original) + for key, val in new.items(): + if key in original and isinstance(original[key], dict) and isinstance(val, dict): + merged[key] = deepmerge(original[key], val) + else: + merged[key] = val + return merged