Procházet zdrojové kódy

Merge branch 'develop' into 4217-contextual-docs

Jeremy Stretch před 6 roky
rodič
revize
4edd0b1fd6

+ 0 - 4
base_requirements.txt

@@ -98,7 +98,3 @@ redis
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite
 svgwrite
-
-# Python package management tool
-# https://pythonwheels.com/
-wheel

+ 15 - 1
docs/release-notes/version-2.7.md

@@ -1,12 +1,26 @@
 # NetBox v2.7 Release Notes
 
-## v2.7.9 (FUTURE)
+## v2.7.10 (FUTURE)
+
+### 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
+
+### 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)
 
 **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/).
 
 ### 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

+ 29 - 0
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(

+ 0 - 839
netbox/dcim/migrations/0071_device_components_add_description_squashed_0088_powerfeed_available_power.py

@@ -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),
-        ),
-    ]

+ 5 - 3
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

+ 1 - 0
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/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
     path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),

+ 9 - 0
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(

+ 2 - 1
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
 

+ 31 - 0
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(),

+ 1 - 1
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

+ 7 - 3
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,

+ 1 - 0
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/<int:pk>/', views.ServiceView.as_view(), name='service'),

+ 7 - 0
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'
 

+ 1 - 1
netbox/netbox/settings.py

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

+ 5 - 0
netbox/templates/inc/nav_menu.html

@@ -338,6 +338,11 @@
                         <li class="divider"></li>
                         <li class="dropdown-header">Services</li>
                         <li{% if not perms.ipam.view_service %} class="disabled"{% endif %}>
+                            {% if perms.ipam.add_service %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'ipam:service_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
                             <a href="{% url 'ipam:service_list' %}">Services</a>
                         </li>
                     </ul>

+ 10 - 8
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
             })
 

+ 0 - 1
requirements.txt

@@ -23,4 +23,3 @@ pycryptodome==3.9.4
 PyYAML==5.3
 redis==3.3.11
 svgwrite==1.3.1
-wheel==0.34.2

+ 13 - 8
upgrade.sh

@@ -29,37 +29,42 @@ 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)..."
-eval $COMMAND
+echo "Installing dependencies ($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
+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"