From 4136a5fd5e7bde6bb091a82de34d5661235bbfc8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 31 Jan 2020 15:33:43 +0100 Subject: [PATCH 01/12] List choices for choice fields as enums Fixes #4062 Signed-off-by: Tomas Slusny --- netbox/utilities/custom_inspectors.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 68fe57d82..553d98982 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -76,26 +76,28 @@ class CustomChoiceFieldInspector(FieldInspector): SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) if isinstance(field, ChoiceField): - value_schema = openapi.Schema(type=openapi.TYPE_STRING) + choices = field._choices + choice_value = list(choices.keys()) + choice_label = list(choices.values()) + value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) - choices = list(field._choices.keys()) - if set([None] + choices) == {None, True, False}: + if set([None] + choice_value) == {None, True, False}: # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be # differentiated since they each have subtly different values in their choice keys. # - subdevice_role and connection_status are booleans, although subdevice_role includes None # - face is an integer set {0, 1} which is easily confused with {False, True} schema_type = openapi.TYPE_STRING - if all(type(x) == bool for x in [c for c in choices if c is not None]): + if all(type(x) == bool for x in [c for c in choice_value if c is not None]): schema_type = openapi.TYPE_BOOLEAN - value_schema = openapi.Schema(type=schema_type) + value_schema = openapi.Schema(type=schema_type, enum=choice_value) value_schema['x-nullable'] = True - if isinstance(choices[0], int): + if isinstance(choice_value[0], int): # Change value_schema for IPAddressFamilyChoices, RackWidthChoices - value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) + value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ - "label": openapi.Schema(type=openapi.TYPE_STRING), + "label": openapi.Schema(type=openapi.TYPE_STRING, enum=choice_label), "value": value_schema }) From 920078a738242a21b7c283d8b25569e1c3646743 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Thu, 5 Mar 2020 23:52:33 -0500 Subject: [PATCH 02/12] set fix#4062 to release note --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 72d88b743..581d395e1 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,6 +5,7 @@ ## Enhancements * [#3949](https://github.com/netbox-community/netbox/issues/3949) - Revised the installation docs and upgrade script to employ a Python virtual environment +* [#4062](https://github.com/netbox-community/netbox/issues/4062) - Enumerate ChoiceField type and value in API * [#4119](https://github.com/netbox-community/netbox/issues/4119) - Extend upgrade script to clear expired user sessions * [#4121](https://github.com/netbox-community/netbox/issues/4121) - Add dynamic lookup expressions for all filters * [#4218](https://github.com/netbox-community/netbox/issues/4218) - Allow negative voltage for DC power feeds From 12bedac28a810ffbecc07e184fdf2320a2c1e09b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 10:26:12 -0500 Subject: [PATCH 03/12] Tweak upgrade script to exit immediately if any individual tasks fail --- upgrade.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index 174382482..bd1a06f67 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -32,32 +32,32 @@ source "${VIRTUALENV}/bin/activate" # Install Python packages COMMAND="pip3 install -r requirements.txt" echo "Installing Python packages ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 # Apply any database migrations COMMAND="python3 netbox/manage.py migrate" echo "Applying database migrations ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 # Collect static files COMMAND="python3 netbox/manage.py collectstatic --no-input" echo "Collecting static files ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 # Delete any stale content types COMMAND="python3 netbox/manage.py remove_stale_contenttypes --no-input" echo "Removing stale content types ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 # Delete any expired user sessions COMMAND="python3 netbox/manage.py clearsessions" echo "Removing expired user sessions ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 # Clear all cached data COMMAND="python3 netbox/manage.py invalidate all" echo "Clearing cache data ($COMMAND)..." -eval $COMMAND +eval $COMMAND || exit 1 if [ WARN_MISSING_VENV ]; then echo "--------------------------------------------------------------------" From afcd9b801f82c2604f6a2cd8ab6538dc3d4f44f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 10:32:12 -0500 Subject: [PATCH 04/12] Delete squashed migration to avoid 'pending trigger events' exception under certain conditions --- ...squashed_0088_powerfeed_available_power.py | 839 ------------------ 1 file changed, 839 deletions(-) delete mode 100644 netbox/dcim/migrations/0071_device_components_add_description_squashed_0088_powerfeed_available_power.py diff --git a/netbox/dcim/migrations/0071_device_components_add_description_squashed_0088_powerfeed_available_power.py b/netbox/dcim/migrations/0071_device_components_add_description_squashed_0088_powerfeed_available_power.py deleted file mode 100644 index f74572c6f..000000000 --- a/netbox/dcim/migrations/0071_device_components_add_description_squashed_0088_powerfeed_available_power.py +++ /dev/null @@ -1,839 +0,0 @@ -import sys - -import django.core.validators -import django.db.models.deletion -import taggit.managers -from django.db import migrations, models - -SITE_STATUS_CHOICES = ( - (1, 'active'), - (2, 'planned'), - (4, 'retired'), -) - -RACK_TYPE_CHOICES = ( - (100, '2-post-frame'), - (200, '4-post-frame'), - (300, '4-post-cabinet'), - (1000, 'wall-frame'), - (1100, 'wall-cabinet'), -) - -RACK_STATUS_CHOICES = ( - (0, 'reserved'), - (1, 'available'), - (2, 'planned'), - (3, 'active'), - (4, 'deprecated'), -) - -RACK_DIMENSION_CHOICES = ( - (1000, 'mm'), - (2000, 'in'), -) - -SUBDEVICE_ROLE_CHOICES = ( - ('true', 'parent'), - ('false', 'child'), -) - -DEVICE_FACE_CHOICES = ( - (0, 'front'), - (1, 'rear'), -) - -DEVICE_STATUS_CHOICES = ( - (0, 'offline'), - (1, 'active'), - (2, 'planned'), - (3, 'staged'), - (4, 'failed'), - (5, 'inventory'), - (6, 'decommissioning'), -) - -INTERFACE_TYPE_CHOICES = ( - (0, 'virtual'), - (200, 'lag'), - (800, '100base-tx'), - (1000, '1000base-t'), - (1050, '1000base-x-gbic'), - (1100, '1000base-x-sfp'), - (1120, '2.5gbase-t'), - (1130, '5gbase-t'), - (1150, '10gbase-t'), - (1170, '10gbase-cx4'), - (1200, '10gbase-x-sfpp'), - (1300, '10gbase-x-xfp'), - (1310, '10gbase-x-xenpak'), - (1320, '10gbase-x-x2'), - (1350, '25gbase-x-sfp28'), - (1400, '40gbase-x-qsfpp'), - (1420, '50gbase-x-sfp28'), - (1500, '100gbase-x-cfp'), - (1510, '100gbase-x-cfp2'), - (1520, '100gbase-x-cfp4'), - (1550, '100gbase-x-cpak'), - (1600, '100gbase-x-qsfp28'), - (1650, '200gbase-x-cfp2'), - (1700, '200gbase-x-qsfp56'), - (1750, '400gbase-x-qsfpdd'), - (1800, '400gbase-x-osfp'), - (2600, 'ieee802.11a'), - (2610, 'ieee802.11g'), - (2620, 'ieee802.11n'), - (2630, 'ieee802.11ac'), - (2640, 'ieee802.11ad'), - (2810, 'gsm'), - (2820, 'cdma'), - (2830, 'lte'), - (6100, 'sonet-oc3'), - (6200, 'sonet-oc12'), - (6300, 'sonet-oc48'), - (6400, 'sonet-oc192'), - (6500, 'sonet-oc768'), - (6600, 'sonet-oc1920'), - (6700, 'sonet-oc3840'), - (3010, '1gfc-sfp'), - (3020, '2gfc-sfp'), - (3040, '4gfc-sfp'), - (3080, '8gfc-sfpp'), - (3160, '16gfc-sfpp'), - (3320, '32gfc-sfp28'), - (3400, '128gfc-sfp28'), - (7010, 'inifiband-sdr'), - (7020, 'inifiband-ddr'), - (7030, 'inifiband-qdr'), - (7040, 'inifiband-fdr10'), - (7050, 'inifiband-fdr'), - (7060, 'inifiband-edr'), - (7070, 'inifiband-hdr'), - (7080, 'inifiband-ndr'), - (7090, 'inifiband-xdr'), - (4000, 't1'), - (4010, 'e1'), - (4040, 't3'), - (4050, 'e3'), - (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'), -) - -INTERFACE_MODE_CHOICES = ( - (100, 'access'), - (200, 'tagged'), - (300, 'tagged-all'), -) - -PORT_TYPE_CHOICES = ( - (1000, '8p8c'), - (1100, '110-punch'), - (1200, 'bnc'), - (2000, 'st'), - (2100, 'sc'), - (2110, 'sc-apc'), - (2200, 'fc'), - (2300, 'lc'), - (2310, 'lc-apc'), - (2400, 'mtrj'), - (2500, 'mpo'), - (2600, 'lsh'), - (2610, 'lsh-apc'), -) - -CABLE_TYPE_CHOICES = ( - (1300, 'cat3'), - (1500, 'cat5'), - (1510, 'cat5e'), - (1600, 'cat6'), - (1610, 'cat6a'), - (1700, 'cat7'), - (1800, 'dac-active'), - (1810, 'dac-passive'), - (1900, 'coaxial'), - (3000, 'mmf'), - (3010, 'mmf-om1'), - (3020, 'mmf-om2'), - (3030, 'mmf-om3'), - (3040, 'mmf-om4'), - (3500, 'smf'), - (3510, 'smf-os1'), - (3520, 'smf-os2'), - (3800, 'aoc'), - (5000, 'power'), -) - -CABLE_STATUS_CHOICES = ( - ('true', 'connected'), - ('false', 'planned'), -) - -CABLE_LENGTH_UNIT_CHOICES = ( - (1200, 'm'), - (1100, 'cm'), - (2100, 'ft'), - (2000, 'in'), -) - -POWERFEED_STATUS_CHOICES = ( - (0, 'offline'), - (1, 'active'), - (2, 'planned'), - (4, 'failed'), -) - -POWERFEED_TYPE_CHOICES = ( - (1, 'primary'), - (2, 'redundant'), -) - -POWERFEED_SUPPLY_CHOICES = ( - (1, 'ac'), - (2, 'dc'), -) - -POWERFEED_PHASE_CHOICES = ( - (1, 'single-phase'), - (3, 'three-phase'), -) - -POWEROUTLET_FEED_LEG_CHOICES_CHOICES = ( - (1, 'A'), - (2, 'B'), - (3, 'C'), -) - - -def cache_cable_devices(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - - if 'test' not in sys.argv: - print("\nUpdating cable device terminations...") - cable_count = Cable.objects.count() - - # Cache A/B termination devices on all existing Cables. Note that the custom save() method on Cable is not - # available during a migration, so we replicate its logic here. - for i, cable in enumerate(Cable.objects.all(), start=1): - - if not i % 1000 and 'test' not in sys.argv: - print("[{}/{}]".format(i, cable_count)) - - termination_a_model = apps.get_model(cable.termination_a_type.app_label, cable.termination_a_type.model) - termination_a_device = None - if hasattr(termination_a_model, 'device'): - termination_a = termination_a_model.objects.get(pk=cable.termination_a_id) - termination_a_device = termination_a.device - - termination_b_model = apps.get_model(cable.termination_b_type.app_label, cable.termination_b_type.model) - termination_b_device = None - if hasattr(termination_b_model, 'device'): - termination_b = termination_b_model.objects.get(pk=cable.termination_b_id) - termination_b_device = termination_b.device - - Cable.objects.filter(pk=cable.pk).update( - _termination_a_device=termination_a_device, - _termination_b_device=termination_b_device - ) - - -def site_status_to_slug(apps, schema_editor): - Site = apps.get_model('dcim', 'Site') - for id, slug in SITE_STATUS_CHOICES: - Site.objects.filter(status=str(id)).update(status=slug) - - -def rack_type_to_slug(apps, schema_editor): - Rack = apps.get_model('dcim', 'Rack') - for id, slug in RACK_TYPE_CHOICES: - Rack.objects.filter(type=str(id)).update(type=slug) - - -def rack_status_to_slug(apps, schema_editor): - Rack = apps.get_model('dcim', 'Rack') - for id, slug in RACK_STATUS_CHOICES: - Rack.objects.filter(status=str(id)).update(status=slug) - - -def rack_outer_unit_to_slug(apps, schema_editor): - Rack = apps.get_model('dcim', 'Rack') - for id, slug in RACK_DIMENSION_CHOICES: - Rack.objects.filter(status=str(id)).update(status=slug) - - -def devicetype_subdevicerole_to_slug(apps, schema_editor): - DeviceType = apps.get_model('dcim', 'DeviceType') - for boolean, slug in SUBDEVICE_ROLE_CHOICES: - DeviceType.objects.filter(subdevice_role=boolean).update(subdevice_role=slug) - - -def device_face_to_slug(apps, schema_editor): - Device = apps.get_model('dcim', 'Device') - for id, slug in DEVICE_FACE_CHOICES: - Device.objects.filter(face=str(id)).update(face=slug) - - -def device_status_to_slug(apps, schema_editor): - Device = apps.get_model('dcim', 'Device') - for id, slug in DEVICE_STATUS_CHOICES: - Device.objects.filter(status=str(id)).update(status=slug) - - -def interfacetemplate_type_to_slug(apps, schema_editor): - InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate') - for id, slug in INTERFACE_TYPE_CHOICES: - InterfaceTemplate.objects.filter(type=id).update(type=slug) - - -def interface_type_to_slug(apps, schema_editor): - Interface = apps.get_model('dcim', 'Interface') - for id, slug in INTERFACE_TYPE_CHOICES: - Interface.objects.filter(type=id).update(type=slug) - - -def interface_mode_to_slug(apps, schema_editor): - Interface = apps.get_model('dcim', 'Interface') - for id, slug in INTERFACE_MODE_CHOICES: - Interface.objects.filter(mode=id).update(mode=slug) - - -def frontporttemplate_type_to_slug(apps, schema_editor): - FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate') - for id, slug in PORT_TYPE_CHOICES: - FrontPortTemplate.objects.filter(type=id).update(type=slug) - - -def rearporttemplate_type_to_slug(apps, schema_editor): - RearPortTemplate = apps.get_model('dcim', 'RearPortTemplate') - for id, slug in PORT_TYPE_CHOICES: - RearPortTemplate.objects.filter(type=id).update(type=slug) - - -def frontport_type_to_slug(apps, schema_editor): - FrontPort = apps.get_model('dcim', 'FrontPort') - for id, slug in PORT_TYPE_CHOICES: - FrontPort.objects.filter(type=id).update(type=slug) - - -def rearport_type_to_slug(apps, schema_editor): - RearPort = apps.get_model('dcim', 'RearPort') - for id, slug in PORT_TYPE_CHOICES: - RearPort.objects.filter(type=id).update(type=slug) - - -def cable_type_to_slug(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - for id, slug in CABLE_TYPE_CHOICES: - Cable.objects.filter(type=id).update(type=slug) - - -def cable_status_to_slug(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - for bool_str, slug in CABLE_STATUS_CHOICES: - Cable.objects.filter(status=bool_str).update(status=slug) - - -def cable_length_unit_to_slug(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - for id, slug in CABLE_LENGTH_UNIT_CHOICES: - Cable.objects.filter(length_unit=id).update(length_unit=slug) - - -def powerfeed_status_to_slug(apps, schema_editor): - PowerFeed = apps.get_model('dcim', 'PowerFeed') - for id, slug in POWERFEED_STATUS_CHOICES: - PowerFeed.objects.filter(status=id).update(status=slug) - - -def powerfeed_type_to_slug(apps, schema_editor): - PowerFeed = apps.get_model('dcim', 'PowerFeed') - for id, slug in POWERFEED_TYPE_CHOICES: - PowerFeed.objects.filter(type=id).update(type=slug) - - -def powerfeed_supply_to_slug(apps, schema_editor): - PowerFeed = apps.get_model('dcim', 'PowerFeed') - for id, slug in POWERFEED_SUPPLY_CHOICES: - PowerFeed.objects.filter(supply=id).update(supply=slug) - - -def powerfeed_phase_to_slug(apps, schema_editor): - PowerFeed = apps.get_model('dcim', 'PowerFeed') - for id, slug in POWERFEED_PHASE_CHOICES: - PowerFeed.objects.filter(phase=id).update(phase=slug) - - -def poweroutlettemplate_feed_leg_to_slug(apps, schema_editor): - PowerOutletTemplate = apps.get_model('dcim', 'PowerOutletTemplate') - for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES: - PowerOutletTemplate.objects.filter(feed_leg=id).update(feed_leg=slug) - - -def poweroutlet_feed_leg_to_slug(apps, schema_editor): - PowerOutlet = apps.get_model('dcim', 'PowerOutlet') - for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES: - PowerOutlet.objects.filter(feed_leg=id).update(feed_leg=slug) - - -class Migration(migrations.Migration): - - replaces = [('dcim', '0071_device_components_add_description'), ('dcim', '0072_powerfeeds'), ('dcim', '0073_interface_form_factor_to_type'), ('dcim', '0074_increase_field_length_platform_name_slug'), ('dcim', '0075_cable_devices'), ('dcim', '0076_console_port_types'), ('dcim', '0077_power_types'), ('dcim', '0078_3569_site_fields'), ('dcim', '0079_3569_rack_fields'), ('dcim', '0080_3569_devicetype_fields'), ('dcim', '0081_3569_device_fields'), ('dcim', '0082_3569_interface_fields'), ('dcim', '0082_3569_port_fields'), ('dcim', '0083_3569_cable_fields'), ('dcim', '0084_3569_powerfeed_fields'), ('dcim', '0085_3569_poweroutlet_fields'), ('dcim', '0086_device_name_nonunique'), ('dcim', '0087_role_descriptions'), ('dcim', '0088_powerfeed_available_power')] - - dependencies = [ - ('dcim', '0070_custom_tag_models'), - ('extras', '0021_add_color_comments_changelog_to_tag'), - ('tenancy', '0006_custom_tag_models'), - ] - - operations = [ - migrations.AddField( - model_name='consoleport', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='consoleserverport', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='devicebay', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='poweroutlet', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='powerport', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.CreateModel( - name='PowerPanel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('name', models.CharField(max_length=50)), - ('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')), - ], - options={ - 'ordering': ['site', 'name'], - 'unique_together': {('site', 'name')}, - }, - ), - migrations.CreateModel( - name='PowerFeed', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('name', models.CharField(max_length=50)), - ('status', models.PositiveSmallIntegerField(default=1)), - ('type', models.PositiveSmallIntegerField(default=1)), - ('supply', models.PositiveSmallIntegerField(default=1)), - ('phase', models.PositiveSmallIntegerField(default=1)), - ('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])), - ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), - ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), - ('available_power', models.PositiveSmallIntegerField(default=0, editable=False)), - ('comments', models.TextField(blank=True)), - ('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')), - ('power_panel', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel')), - ('rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack')), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags')), - ('connected_endpoint', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort')), - ('connection_status', models.NullBooleanField()), - ], - options={ - 'ordering': ['power_panel', 'name'], - 'unique_together': {('power_panel', 'name')}, - }, - ), - migrations.RenameField( - model_name='powerport', - old_name='connected_endpoint', - new_name='_connected_poweroutlet', - ), - migrations.AddField( - model_name='powerport', - name='_connected_powerfeed', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'), - ), - migrations.AddField( - model_name='powerport', - name='allocated_draw', - field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), - ), - migrations.AddField( - model_name='powerport', - name='maximum_draw', - field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), - ), - migrations.AddField( - model_name='powerporttemplate', - name='allocated_draw', - field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), - ), - migrations.AddField( - model_name='powerporttemplate', - name='maximum_draw', - field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), - ), - migrations.AddField( - model_name='poweroutlet', - name='feed_leg', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='poweroutlet', - name='power_port', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='feed_leg', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='power_port', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'), - ), - migrations.RenameField( - model_name='interface', - old_name='form_factor', - new_name='type', - ), - migrations.RenameField( - model_name='interfacetemplate', - old_name='form_factor', - new_name='type', - ), - migrations.AlterField( - model_name='platform', - name='name', - field=models.CharField(max_length=100, unique=True), - ), - migrations.AlterField( - model_name='platform', - name='slug', - field=models.SlugField(max_length=100, unique=True), - ), - migrations.AddField( - model_name='cable', - name='_termination_a_device', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'), - ), - migrations.AddField( - model_name='cable', - name='_termination_b_device', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'), - ), - migrations.RunPython( - code=cache_cable_devices, - reverse_code=django.db.migrations.operations.special.RunPython.noop, - ), - migrations.AddField( - model_name='consoleport', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='consoleporttemplate', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='consoleserverport', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='consoleserverporttemplate', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='poweroutlet', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='powerport', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='powerporttemplate', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='site', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=site_status_to_slug, - ), - migrations.AlterField( - model_name='rack', - name='type', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=rack_type_to_slug, - ), - migrations.AlterField( - model_name='rack', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='rack', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=rack_status_to_slug, - ), - migrations.AlterField( - model_name='rack', - name='outer_unit', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=rack_outer_unit_to_slug, - ), - migrations.AlterField( - model_name='rack', - name='outer_unit', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='devicetype', - name='subdevice_role', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=devicetype_subdevicerole_to_slug, - ), - migrations.AlterField( - model_name='devicetype', - name='subdevice_role', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='device', - name='face', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=device_face_to_slug, - ), - migrations.AlterField( - model_name='device', - name='face', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='device', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=device_status_to_slug, - ), - migrations.AlterField( - model_name='interfacetemplate', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=interfacetemplate_type_to_slug, - ), - migrations.AlterField( - model_name='interface', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=interface_type_to_slug, - ), - migrations.AlterField( - model_name='interface', - name='mode', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=interface_mode_to_slug, - ), - migrations.AlterField( - model_name='interface', - name='mode', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='frontporttemplate', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=frontporttemplate_type_to_slug, - ), - migrations.AlterField( - model_name='rearporttemplate', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=rearporttemplate_type_to_slug, - ), - migrations.AlterField( - model_name='frontport', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=frontport_type_to_slug, - ), - migrations.AlterField( - model_name='rearport', - name='type', - field=models.CharField(max_length=50), - ), - migrations.RunPython( - code=rearport_type_to_slug, - ), - migrations.AlterField( - model_name='cable', - name='type', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=cable_type_to_slug, - ), - migrations.AlterField( - model_name='cable', - name='type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='cable', - name='status', - field=models.CharField(default='connected', max_length=50), - ), - migrations.RunPython( - code=cable_status_to_slug, - ), - migrations.AlterField( - model_name='cable', - name='length_unit', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=cable_length_unit_to_slug, - ), - migrations.AlterField( - model_name='cable', - name='length_unit', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='powerfeed', - name='status', - field=models.CharField(default='active', max_length=50), - ), - migrations.RunPython( - code=powerfeed_status_to_slug, - ), - migrations.AlterField( - model_name='powerfeed', - name='type', - field=models.CharField(default='primary', max_length=50), - ), - migrations.RunPython( - code=powerfeed_type_to_slug, - ), - migrations.AlterField( - model_name='powerfeed', - name='supply', - field=models.CharField(default='ac', max_length=50), - ), - migrations.RunPython( - code=powerfeed_supply_to_slug, - ), - migrations.AlterField( - model_name='powerfeed', - name='phase', - field=models.CharField(default='single-phase', max_length=50), - ), - migrations.RunPython( - code=powerfeed_phase_to_slug, - ), - migrations.AlterField( - model_name='poweroutlettemplate', - name='feed_leg', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=poweroutlettemplate_feed_leg_to_slug, - ), - migrations.AlterField( - model_name='poweroutlettemplate', - name='feed_leg', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='poweroutlet', - name='feed_leg', - field=models.CharField(blank=True, default='', max_length=50), - ), - migrations.RunPython( - code=poweroutlet_feed_leg_to_slug, - ), - migrations.AlterField( - model_name='poweroutlet', - name='feed_leg', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AlterField( - model_name='device', - name='name', - field=models.CharField(blank=True, max_length=64, null=True), - ), - migrations.AlterUniqueTogether( - name='device', - unique_together={('rack', 'position', 'face'), ('site', 'tenant', 'name'), ('virtual_chassis', 'vc_position')}, - ), - migrations.AddField( - model_name='devicerole', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='rackrole', - name='description', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name='powerfeed', - name='available_power', - field=models.PositiveIntegerField(default=0, editable=False), - ), - ] From 09298dab7ad58bc3d75d5de5889bec547d885571 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 11:17:17 -0500 Subject: [PATCH 05/12] Release v2.7.9 --- docs/release-notes/version-2.7.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 581d395e1..07457fba8 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,4 +1,4 @@ -# v2.7.9 (FUTURE) +# v2.7.9 (2020-03-06) **Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/). diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7f4f44b1a..19e124abc 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.7.9-dev' +VERSION = '2.7.9' # Hostname HOSTNAME = platform.node() From 5950bedfae7c06d8f38c4925a2a4fb737099b487 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 11:26:59 -0500 Subject: [PATCH 06/12] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 19e124abc..99ef6ee5e 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.7.9' +VERSION = '2.7.10-dev' # Hostname HOSTNAME = platform.node() From 17c76e413df28e90cf952bd28ec29ac9558441a4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 13:59:19 -0500 Subject: [PATCH 07/12] Install wheel before NetBox dependencies --- base_requirements.txt | 4 ---- requirements.txt | 1 - upgrade.sh | 7 ++++++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index ab33b1c06..0438d8025 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -102,7 +102,3 @@ redis # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite svgwrite - -# Python package management tool -# https://pythonwheels.com/ -wheel diff --git a/requirements.txt b/requirements.txt index d8e704874..758d2d98b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,4 +24,3 @@ pycryptodome==3.9.4 PyYAML==5.3 redis==3.3.11 svgwrite==1.3.1 -wheel==0.34.2 diff --git a/upgrade.sh b/upgrade.sh index bd1a06f67..fe1df715b 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -29,9 +29,14 @@ eval $COMMAND || { # Activate the virtual environment source "${VIRTUALENV}/bin/activate" +# Install necessary system packages +COMMAND="pip3 install wheel" +echo "Installing Python system packages ($COMMAND)..." +eval $COMMAND || exit 1 + # Install Python packages COMMAND="pip3 install -r requirements.txt" -echo "Installing Python packages ($COMMAND)..." +echo "Installing dependencies ($COMMAND)..." eval $COMMAND || exit 1 # Apply any database migrations From b22bf0c4b0e45eebc1af2febe573b559b718c077 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 15:07:29 -0500 Subject: [PATCH 08/12] Fix upgrade.sh to show virtualenv warning only when needed --- upgrade.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upgrade.sh b/upgrade.sh index fe1df715b..43922dc21 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -64,7 +64,7 @@ COMMAND="python3 netbox/manage.py invalidate all" echo "Clearing cache data ($COMMAND)..." eval $COMMAND || exit 1 -if [ WARN_MISSING_VENV ]; then +if [ -v WARN_MISSING_VENV ]; then echo "--------------------------------------------------------------------" echo "WARNING: No existing virtual environment was detected. A new one has" echo "been created. Update your systemd service files to reflect the new" From 2a5bf2a222ab39528c75312d8d45f670c786c4a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 16:05:26 -0500 Subject: [PATCH 09/12] Fixes #4323: Add bulk edit view for power panels --- docs/release-notes/version-2.7.md | 8 ++++++++ netbox/dcim/forms.py | 29 +++++++++++++++++++++++++++++ netbox/dcim/tests/test_views.py | 8 +++++--- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 9 +++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 07457fba8..8d95390bc 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.10 (FUTURE) + +## Enhancements + +* [#4323](https://github.com/netbox-community/netbox/issues/4323) - Add bulk edit view for power panels + +--- + # v2.7.9 (2020-03-06) **Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/). diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9ba5cb7c2..2b5aaee7e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4621,6 +4621,35 @@ class PowerPanelCSVForm(forms.ModelForm): ) +class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPanel.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ) + ) + rack_group = DynamicModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/rack-groups/" + ) + ) + + class Meta: + nullable_fields = ( + 'rack_group', + ) + + class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): model = PowerPanel q = forms.CharField( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 704dedb40..997626152 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1553,9 +1553,6 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerPanel - # Disable inapplicable tests - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -1590,6 +1587,11 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 1,Rack Group 1,Power Panel 6", ) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'rack_group': rackgroups[1].pk, + } + class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerFeed diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 165ca9e02..130a79199 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -331,6 +331,7 @@ urlpatterns = [ path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), + path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8f9da2d68..0a6888884 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2569,6 +2569,15 @@ class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:powerpanel_list' +class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerpanel' + queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') + filterset = filters.PowerPanelFilterSet + table = tables.PowerPanelTable + form = forms.PowerPanelBulkEditForm + default_return_url = 'dcim:powerpanel_list' + + class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerpanel' queryset = PowerPanel.objects.prefetch_related( From 9fa5004a35c5e5ac4f2c06e49c5cfabf121b13a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 16:33:43 -0500 Subject: [PATCH 10/12] Closes #4324: Add CSV import view for services --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/forms.py | 31 ++++++++++++++++++++++++++++++ netbox/ipam/models.py | 2 +- netbox/ipam/tests/test_views.py | 10 +++++++--- netbox/ipam/urls.py | 1 + netbox/ipam/views.py | 7 +++++++ netbox/templates/inc/nav_menu.html | 5 +++++ 7 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 8d95390bc..dc32f2a57 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#4323](https://github.com/netbox-community/netbox/issues/4323) - Add bulk edit view for power panels +* [#4324](https://github.com/netbox-community/netbox/issues/4324) - Add CSV import view for services --- diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8156ae4aa..f9c6fe515 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1382,6 +1382,37 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): tag = TagFilterField(model) +class ServiceCSVForm(CustomFieldModelCSVForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + virtual_machine = FlexibleModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of virtual machine', + error_messages={ + 'invalid_choice': 'Virtual machine not found.', + } + ) + protocol = CSVChoiceField( + choices=ServiceProtocolChoices, + help_text='IP protocol' + ) + + class Meta: + model = Service + fields = Service.csv_headers + help_texts = { + } + + class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Service.objects.all(), diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b4ba92fb5..4cbcb4bf0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1027,7 +1027,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] class Meta: ordering = ('protocol', 'port', 'pk') # (protocol, port) may be non-unique diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 66e649005..ba9db74f7 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -334,9 +334,6 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service - # Disable inapplicable tests - test_import_objects = None - # TODO: Resolve URL for Service creation test_create_object = None @@ -366,6 +363,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': 'Alpha,Bravo,Charlie', } + cls.csv_data = ( + "device,name,protocol,port,description", + "Device 1,Service 1,TCP,1,First service", + "Device 1,Service 2,TCP,2,Second service", + "Device 1,Service 3,UDP,3,Third service", + ) + cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'port': 888, diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 604287f24..f1211473e 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -94,6 +94,7 @@ urlpatterns = [ # Services path('services/', views.ServiceListView.as_view(), name='service_list'), + path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), path('services//', views.ServiceView.as_view(), name='service'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 053098f0b..8a3057faa 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1015,6 +1015,13 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): return service.parent.get_absolute_url() +class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_service' + model_form = forms.ServiceCSVForm + table = tables.ServiceTable + default_return_url = 'ipam:service_list' + + class ServiceEditView(ServiceCreateView): permission_required = 'ipam.change_service' diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 900d783f6..355d0d8bb 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -338,6 +338,11 @@
  • + {% if perms.ipam.add_vservice %} +
    + +
    + {% endif %} Services From 9a829500cd7506e599fa6184d95ac9b095550490 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 16:40:00 -0500 Subject: [PATCH 11/12] Fix typo --- netbox/templates/inc/nav_menu.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 355d0d8bb..11daa2277 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -338,7 +338,7 @@
  • - {% if perms.ipam.add_vservice %} + {% if perms.ipam.add_service %}
    From f9073a2f07e8965d1ff4e5da458d8838d2f3a55f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Mar 2020 20:59:32 -0500 Subject: [PATCH 12/12] Fixes #4326: Exclude Python modules without Script classes from scripts list --- docs/release-notes/version-2.7.md | 4 ++++ netbox/extras/scripts.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index dc32f2a57..576543494 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,6 +5,10 @@ * [#4323](https://github.com/netbox-community/netbox/issues/4323) - Add bulk edit view for power panels * [#4324](https://github.com/netbox-community/netbox/issues/4324) - Add CSV import view for services +## Bug Fixes + +* [#4326](https://github.com/netbox-community/netbox/issues/4326) - Exclude Python modules without Script classes from scripts list + --- # v2.7.9 (2020-03-06) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 97fc50ea0..e0db71f21 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -438,7 +438,8 @@ def get_scripts(use_names=False): module_scripts = OrderedDict() for name, cls in inspect.getmembers(module, is_script): module_scripts[name] = cls - scripts[module_name] = module_scripts + if module_scripts: + scripts[module_name] = module_scripts return scripts