diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 244ffc120..651781c17 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## RQ_DEFAULT_TIMEOUT + +Default: `300` + +The maximum execution time of a background task (such as running a custom script), in seconds. + +--- + ## SCRIPTS_ROOT Default: `$INSTALL_ROOT/netbox/scripts/` diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index d54b13e38..2edf6c7c7 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes * `PORT` - TCP port of the Redis service; leave blank for default port (6379) * `PASSWORD` - Redis password (if set) * `DATABASE` - Numeric database ID -* `DEFAULT_TIMEOUT` - Connection timeout in seconds * `SSL` - Use SSL connection to Redis An example configuration is provided below: @@ -77,7 +76,6 @@ REDIS = { 'PORT': 1234, 'PASSWORD': 'foobar', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -85,7 +83,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } @@ -109,6 +106,7 @@ above and the addition of two new keys. * `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address of the Redis server and port for each sentinel instance to connect to * `SENTINEL_SERVICE`: Name of the master / service to connect to +* `SENTINEL_TIMEOUT`: Connection timeout, in seconds Example: @@ -117,9 +115,9 @@ REDIS = { 'tasks': { 'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINEL_SERVICE': 'netbox', + 'SENTINEL_TIMEOUT': 10, 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -130,7 +128,6 @@ REDIS = { 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 05f6d825e..54ecd284a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies. Before continuing with either platform, update pip (Python's package management tool) to its latest release: ```no-highlight -# pip install --upgrade pip +# pip3 install --upgrade pip ``` ## Download NetBox @@ -163,7 +163,6 @@ REDIS = { 'PORT': 6379, # Redis port 'PASSWORD': '', # Redis password (optional) 'DATABASE': 0, # Database ID - 'DEFAULT_TIMEOUT': 300, # Timeout (seconds) 'SSL': False, # Use SSL (optional) }, 'caching': { @@ -171,7 +170,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, # Unique ID for second database - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/docs/models/dcim/rearporttemplate.md b/docs/models/dcim/rearporttemplate.md index 71d9a200b..01ba02ac0 100644 --- a/docs/models/dcim/rearporttemplate.md +++ b/docs/models/dcim/rearporttemplate.md @@ -1,3 +1,3 @@ ## Rear Port Templates -A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64). +A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024). diff --git a/docs/plugins/development.md b/docs/plugins/development.md index b704ad7fc..f4db3c84d 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes: * `color` - One of the choices provided by `ButtonColorChoices` (optional) * `permissions` - A list of permissions required to display this button (optional) +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + ## Extending Core Templates Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index d407026e0..d0d34e1cc 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,6 +1,68 @@ # NetBox v2.9 -## v2.9.2 (FUTURE) +## v2.9.4 (2020-09-23) + +**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead. + +**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model. + +### Enhancements + +* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed +* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024 +* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks +* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form +* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view +* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter + +### Bug Fixes + +* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release +* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission +* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces +* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM +* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data +* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI +* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params` +* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API) +* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface +* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode +* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list +* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users + +--- + +## v2.9.3 (2020-09-04) + +### Enhancements + +* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view +* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component +* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments +* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types + +### Bug Fixes + +* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable +* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices +* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master +* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI +* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field +* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table +* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets +* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component +* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections +* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences +* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list + +--- + +## v2.9.2 (2020-08-27) + +### Enhancements + +* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables +* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list ### Bug Fixes @@ -12,6 +74,10 @@ * [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface * [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status * [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import +* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage +* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view +* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices +* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces --- @@ -87,6 +153,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip * If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved. * If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. +* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases. ### REST API Changes diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index dc12e686e..fa4f81792 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet): TYPE_8P8C = '8p8c' + TYPE_8P6C = '8p6c' + TYPE_8P4C = '8p4c' + TYPE_8P2C = '8p2c' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' TYPE_MRJ21 = 'mrj21' @@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet): 'Copper', ( (TYPE_8P8C, '8P8C'), + (TYPE_8P6C, '8P6C'), + (TYPE_8P4C, '8P4C'), + (TYPE_8P2C, '8P2C'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), (TYPE_MRJ21, 'MRJ21'), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 66768515c..961c458e0 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 # REARPORT_POSITIONS_MIN = 1 -REARPORT_POSITIONS_MAX = 64 +REARPORT_POSITIONS_MAX = 1024 # diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index cef95a7b6..93c44f087 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/elevations.py @@ -94,8 +94,12 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: - url = '{}{}'.format(self.base_url, device.device_type.front_image.url) - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.front_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') link.add(image) @@ -107,8 +111,12 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: - url = device.device_type.rear_image.url - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.rear_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') drawing.add(image) @@ -141,7 +149,7 @@ class RackElevationSVG: unit_cursor = 0 for u in elevation: o = other[unit_cursor] - if not u['device'] and o['device']: + if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: u['device'] = o['device'] u['height'] = 1 unit_cursor += u.get('height', 1) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b6ba55d6d..d3c385121 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'region_id': '$region' } ) + rack_group = DynamicModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + display_field='display_name', + query_params={ + 'site_id': '$site' + } + ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, display_field='display_name', query_params={ - 'site_id': '$site' + 'site_id': '$site', + 'group_id': '$rack_group', } ) position = forms.TypedChoiceField( @@ -2317,7 +2326,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2390,7 +2399,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2479,7 +2488,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags', + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/migrations/0116_rearport_max_positions.py b/netbox/dcim/migrations/0116_rearport_max_positions.py new file mode 100644 index 000000000..a03f4e3d5 --- /dev/null +++ b/netbox/dcim/migrations/0116_rearport_max_positions.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-09-16 16:51 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0115_rackreservation_order'), + ] + + operations = [ + migrations.AlterField( + model_name='frontport', + name='rear_port_position', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='rear_port_position', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='rearport', + name='positions', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='positions', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 492fe3762..7a94b3e1b 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel): ) rear_port_position = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) class Meta: @@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel): ) positions = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) class Meta: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d7e077a16..18ca5cf3e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -809,7 +809,10 @@ class FrontPort(CableTermination, ComponentModel): ) rear_port_position = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) tags = TaggableManager(through=TaggedItem) @@ -864,7 +867,10 @@ class RearPort(CableTermination, ComponentModel): ) positions = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) tags = TaggableManager(through=TaggedItem) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4189e0446..e96becadf 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -633,7 +633,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. - if self.name and self.tenant is None: + if self.name and hasattr(self, 'site') and self.tenant is None: if Device.objects.exclude(pk=self.pk).filter( name=self.name, site=self.site, diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e48eaedba..371eff9db 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ +CONNECTION_STATUS = """ +{{ record.get_connection_status_display }} +""" + # # Regions @@ -706,34 +710,48 @@ class DeviceComponentTable(BaseTable): class ConsolePortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleport_list' + ) class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') class ConsoleServerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleserverport_list' + ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') class PowerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:powerport_list' + ) class Meta(DeviceComponentTable.Meta): model = PowerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:poweroutlet_list' + ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -753,12 +771,15 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): + tags = TagColumn( + url_name='dcim:interface_list' + ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -767,18 +788,26 @@ class FrontPortTable(DeviceComponentTable): rear_port_position = tables.Column( verbose_name='Position' ) + tags = TagColumn( + url_name='dcim:frontport_list' + ) class Meta(DeviceComponentTable.Meta): model = FrontPort - fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') class RearPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:rearport_list' + ) class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -786,10 +815,13 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) + tags = TagColumn( + url_name='dcim:devicebay_list' + ) class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description') + fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') @@ -798,12 +830,16 @@ class InventoryItemTable(DeviceComponentTable): linkify=True ) discovered = BooleanColumn() + tags = TagColumn( + url_name='dcim:inventoryitem_list' + ) + cable = None # Override DeviceComponentTable class Meta(DeviceComponentTable.Meta): model = InventoryItem fields = ( 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', + 'discovered', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') @@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable): verbose_name='Console Server' ) connected_endpoint = tables.Column( + linkify=True, verbose_name='Port' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Console Port' ) - connection_status = BooleanColumn() + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = ConsolePort @@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable): ) outlet = tables.Column( accessor=Accessor('_connected_poweroutlet'), + linkify=True, verbose_name='Outlet' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Power Port' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = PowerPort @@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable): args=[Accessor('_connected_interface__pk')], verbose_name='Interface B' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = Interface diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c016f6e54..dece687ee 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -169,9 +169,13 @@ class SiteView(ObjectView): 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(), } - rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( - rack_count=Count('racks') - ) + rack_groups = RackGroup.objects.add_related_count( + RackGroup.objects.all(), + Rack, + 'group', + 'rack_count', + cumulative=True + ).restrict(request.user, 'view').filter(site=site) show_graphs = Graph.objects.filter(type__model='site').exists() return render(request, 'dcim/site.html', { @@ -310,6 +314,11 @@ class RackElevationListView(ObjectListView): racks = filters.RackFilterSet(request.GET, self.queryset).qs total_count = racks.count() + # Determine ordering + reverse = bool(request.GET.get('reverse', False)) + if reverse: + racks = racks.reverse() + # Pagination per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) page_number = request.GET.get('page', 1) @@ -330,6 +339,7 @@ class RackElevationListView(ObjectListView): 'paginator': paginator, 'page': page, 'total_count': total_count, + 'reverse': reverse, 'rack_face': rack_face, 'filter_form': forms.RackElevationFilterForm(request.GET), }) @@ -408,7 +418,6 @@ class RackReservationListView(ObjectListView): filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable - action_buttons = ('export',) class RackReservationView(ObjectView): @@ -1033,7 +1042,7 @@ class DeviceView(ObjectView): ) # Interfaces - interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', @@ -1233,6 +1242,7 @@ class ConsolePortCreateView(ComponentCreateView): class ConsolePortEditView(ObjectEditView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm + template_name = 'dcim/device_component_edit.html' class ConsolePortDeleteView(ObjectDeleteView): @@ -1292,6 +1302,7 @@ class ConsoleServerPortCreateView(ComponentCreateView): class ConsoleServerPortEditView(ObjectEditView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm + template_name = 'dcim/device_component_edit.html' class ConsoleServerPortDeleteView(ObjectDeleteView): @@ -1351,6 +1362,7 @@ class PowerPortCreateView(ComponentCreateView): class PowerPortEditView(ObjectEditView): queryset = PowerPort.objects.all() model_form = forms.PowerPortForm + template_name = 'dcim/device_component_edit.html' class PowerPortDeleteView(ObjectDeleteView): @@ -1410,6 +1422,7 @@ class PowerOutletCreateView(ComponentCreateView): class PowerOutletEditView(ObjectEditView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm + template_name = 'dcim/device_component_edit.html' class PowerOutletDeleteView(ObjectDeleteView): @@ -1561,6 +1574,7 @@ class FrontPortCreateView(ComponentCreateView): class FrontPortEditView(ObjectEditView): queryset = FrontPort.objects.all() model_form = forms.FrontPortForm + template_name = 'dcim/device_component_edit.html' class FrontPortDeleteView(ObjectDeleteView): @@ -1620,6 +1634,7 @@ class RearPortCreateView(ComponentCreateView): class RearPortEditView(ObjectEditView): queryset = RearPort.objects.all() model_form = forms.RearPortForm + template_name = 'dcim/device_component_edit.html' class RearPortDeleteView(ObjectDeleteView): @@ -1679,6 +1694,7 @@ class DeviceBayCreateView(ComponentCreateView): class DeviceBayEditView(ObjectEditView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm + template_name = 'dcim/device_component_edit.html' class DeviceBayDeleteView(ObjectDeleteView): diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5ef983977..ba59e529a 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields = {} for field in custom_fields: value = instance.cf.get(field.name) - if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None: + if field.type == CustomFieldTypeChoices.TYPE_SELECT and type(value) is CustomFieldChoice: instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data else: instance.custom_fields[field.name] = value diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index aa8f6ba69..3cc2d1991 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -101,24 +101,30 @@ class TaggedObjectSerializer(serializers.Serializer): tags = NestedTagSerializer(many=True, required=False) def create(self, validated_data): - tags = validated_data.pop('tags', []) + tags = validated_data.pop('tags', None) instance = super().create(validated_data) - return self._save_tags(instance, tags) + if tags is not None: + return self._save_tags(instance, tags) + return instance def update(self, instance, validated_data): - tags = validated_data.pop('tags', []) + tags = validated_data.pop('tags', None) # Cache tags on instance for change logging - instance._tags = tags + instance._tags = tags or [] instance = super().update(instance, validated_data) - return self._save_tags(instance, tags) + if tags is not None: + return self._save_tags(instance, tags) + return instance def _save_tags(self, instance, tags): if tags: instance.tags.set(*[t.name for t in tags]) + else: + instance.tags.clear() return instance diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 289a51c83..a63dbe44d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -140,6 +140,7 @@ class ImageAttachmentViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer + filterset_class = filters.ImageAttachmentFilterSet # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index e8962da01..865c693e7 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,4 +1,5 @@ import django_filters +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -7,7 +8,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag +from .models import ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange, Tag __all__ = ( @@ -17,6 +18,7 @@ __all__ = ( 'CustomFieldFilterSet', 'ExportTemplateFilterSet', 'GraphFilterSet', + 'ImageAttachmentFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -104,6 +106,13 @@ class ExportTemplateFilterSet(BaseFilterSet): fields = ['id', 'content_type', 'name', 'template_language'] +class ImageAttachmentFilterSet(BaseFilterSet): + + class Meta: + model = ImageAttachment + fields = ['id', 'content_type', 'object_id', 'name'] + + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -251,12 +260,21 @@ class ObjectChangeFilterSet(BaseFilterSet): label='Search', ) time = django_filters.DateTimeFromToRangeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=User.objects.all(), + to_field_name='username', + label='User name', + ) class Meta: model = ObjectChange fields = [ - 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', - 'object_repr', + 'id', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 90ec828c7..c88e66262 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -397,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2() ) - user = DynamicModelMultipleChoiceField( + user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, display_field='username', + label='User', widget=APISelectMultiple( api_url='/api/users/users/', ) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index efc789021..de7c5c91b 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,7 +1,12 @@ +import time + +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from django.utils import timezone -from extras.reports import get_reports +from extras.choices import JobResultStatusChoices +from extras.models import JobResult +from extras.reports import get_reports, run_report class Command(BaseCommand): @@ -20,15 +25,33 @@ class Command(BaseCommand): for report in report_list: if module_name in options['reports'] or report.full_name in options['reports']: - # Run the report and create a new ReportResult + # Run the report and create a new JobResult self.stdout.write( "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) ) - report.run() + + report_content_type = ContentType.objects.get(app_label='extras', model='report') + job_result = JobResult.enqueue_job( + run_report, + report.full_name, + report_content_type, + None + ) + + # Wait on the job to finish + while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES: + time.sleep(1) + job_result = JobResult.objects.get(pk=job_result.pk) # Report on success/failure - status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in report.result.data.items(): + if job_result.status == JobResultStatusChoices.STATUS_FAILED: + status = self.style.ERROR('FAILED') + elif job_result == JobResultStatusChoices.STATUS_ERRORED: + status = self.style.ERROR('ERRORED') + else: + status = self.style.SUCCESS('SUCCESS') + + for test_name, attrs in job_result.data.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failure".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] @@ -37,6 +60,9 @@ class Command(BaseCommand): self.stdout.write( "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) ) + self.stdout.write( + "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration) + ) # Wrap things up self.stdout.write( diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 72db138e2..d6e077db4 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Rack, Region, Site from extras.choices import * from extras.filters import * from extras.utils import FeatureQuery -from extras.models import ConfigContext, ExportTemplate, Graph, Tag +from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ImageAttachmentTestCase(TestCase): + queryset = ImageAttachment.objects.all() + filterset = ImageAttachmentFilterSet + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get(app_label='dcim', model='site') + rack_ct = ContentType.objects.get(app_label='dcim', model='rack') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) + + image_attachments = ( + ImageAttachment( + content_type=site_ct, + object_id=sites[0].pk, + name='Image Attachment 1', + image='http://example.com/image1.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=site_ct, + object_id=sites[1].pk, + name='Image Attachment 2', + image='http://example.com/image2.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[0].pk, + name='Image Attachment 3', + image='http://example.com/image3.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[1].pk, + name='Image Attachment 4', + image='http://example.com/image4.png', + image_height=100, + image_width=100 + ) + ) + ImageAttachment.objects.bulk_create(image_attachments) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Image Attachment 1', 'Image Attachment 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type_and_object_id(self): + params = { + 'content_type': ContentType.objects.get(app_label='dcim', model='site').pk, + 'object_id': [Site.objects.first().pk], + } + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ConfigContextTestCase(TestCase): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index 694cd77d9..39aae49dc 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase): sorted([t.name for t in site.tags.all()]), sorted(["Foo", "Bar", "New Tag"]) ) + + def test_clear_tagged_item(self): + site = Site.objects.create( + name='Test Site', + slug='test-site' + ) + site.tags.add("Foo", "Bar", "Baz") + data = { + 'tags': [] + } + self.add_permissions('dcim.change_site') + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['tags']), 0) + site = Site.objects.get(pk=response.data['id']) + self.assertEqual(len(site.tags.all()), 0) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index cd813d280..56477a59f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): Retrieve all of the available reports from disk and the recorded JobResult (if any) for each. """ def get_required_permission(self): - return 'extras.view_reportresult' + return 'extras.view_report' def get(self, request): @@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): Display a single Report and its associated JobResult (if any). """ def get_required_permission(self): - return 'extras.view_reportresult' + return 'extras.view_report' def get(self, request, module, name): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 280bb4322..dd0731bb8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' ) serializer_class = serializers.IPAddressSerializer filterset_class = filters.IPAddressFilterSet diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1e8e9038a..75a4caf10 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel self.initial['primary_for_parent'] = True def clean(self): - super().clean() # Cannot select both a device interface and a VM interface if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') @@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel ) def save(self, *args, **kwargs): - - # Set assigned object - interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') - if interface: - self.instance.assigned_object = interface - ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. + interface = self.instance.assigned_object if interface and self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: interface.parent.primary_ip4 = ipaddress else: - interface.primary_ip6 = ipaddress + interface.parent.primary_ip6 = ipaddress interface.parent.save() elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: interface.parent.primary_ip4 = None interface.parent.save() elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: - interface.parent.primary_ip4 = None + interface.parent.primary_ip6 = None interface.parent.save() return ipaddress diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 832e09330..a69d235be 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -726,30 +726,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): }) # Check for primary IP assignment that doesn't match the assigned device/VM - if self.pk and type(self.assigned_object) is Interface: + if self.pk: device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if device: - if self.assigned_object is None: + if getattr(self.assigned_object, 'device', None) != device: raise ValidationError({ - 'interface': f"IP address is primary for device {device} but not assigned to an interface" + 'interface': f"IP address is primary for device {device} but not assigned to it!" }) - elif self.assigned_object.device != device: - raise ValidationError({ - 'interface': f"IP address is primary for device {device} but assigned to " - f"{self.assigned_object.device} ({self.assigned_object})" - }) - elif self.pk and type(self.assigned_object) is VMInterface: vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if vm: - if self.assigned_object is None: + if getattr(self.assigned_object, 'virtual_machine', None) != vm: raise ValidationError({ - 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " - f"interface" - }) - elif self.assigned_object.virtual_machine != vm: - raise ValidationError({ - 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to " - f"{self.assigned_object.virtual_machine} ({self.assigned_object})" + 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!" }) # Validate IP status selection @@ -997,13 +985,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return self.STATUS_CLASS_MAP[self.status] - def get_members(self): - # Return all interfaces assigned to this VLAN + def get_interfaces(self): + # Return all device interfaces assigned to this VLAN return Interface.objects.filter( Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + def get_vminterfaces(self): + # Return all VM interfaces assigned to this VLAN + return VMInterface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Service(ChangeLoggedModel, CustomFieldModel): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 5a4e2c133..ba09475b0 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -4,6 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn +from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """ @@ -67,11 +68,7 @@ IPADDRESS_LINK = """ """ IPADDRESS_ASSIGN_LINK = """ -{% if request.GET %} - {{ record }} -{% else %} - {{ record }} -{% endif %} +{{ record }} """ VRF_LINK = """ @@ -103,7 +100,7 @@ VLAN_LINK = """ """ VLAN_PREFIXES = """ -{% for prefix in record.prefixes.unrestricted %} +{% for prefix in record.prefixes.all %} {{ prefix }}{% if not forloop.last %}
{% endif %} {% empty %} — @@ -128,9 +125,11 @@ VLANGROUP_ADD_VLAN = """ {% endwith %} """ -VLAN_MEMBER_UNTAGGED = """ +VLAN_MEMBER_TAGGED = """ {% if record.untagged_vlan_id == vlan.pk %} - + +{% else %} + {% endif %} """ @@ -387,15 +386,23 @@ class IPAddressTable(BaseTable): tenant = tables.TemplateColumn( template_code=TENANT_LINK ) - assigned = tables.BooleanColumn( - accessor='assigned_object_id', - verbose_name='Assigned' + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Interface' + ) + assigned_object_parent = tables.Column( + accessor='assigned_object__parent', + linkify=True, + orderable=False, + verbose_name='Interface Parent' ) class Meta(BaseTable.Meta): model = IPAddress fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name', + 'description', ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', @@ -411,6 +418,10 @@ class IPAddressDetailTable(IPAddressTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) + assigned = BooleanColumn( + accessor='assigned_object_id', + verbose_name='Assigned' + ) tags = TagColumn( url_name='ipam:ipaddress_list' ) @@ -545,15 +556,15 @@ class VLANDetailTable(VLANTable): default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') -class VLANMemberTable(BaseTable): - parent = tables.LinkColumn( - order_by=['device', 'virtual_machine'] - ) +class VLANMembersTable(BaseTable): + """ + Base table for Interface and VMInterface assignments + """ name = tables.LinkColumn( verbose_name='Interface' ) - untagged = tables.TemplateColumn( - template_code=VLAN_MEMBER_UNTAGGED, + tagged = tables.TemplateColumn( + template_code=VLAN_MEMBER_TAGGED, orderable=False ) actions = tables.TemplateColumn( @@ -562,9 +573,21 @@ class VLANMemberTable(BaseTable): verbose_name='' ) + +class VLANDevicesTable(VLANMembersTable): + device = tables.LinkColumn() + class Meta(BaseTable.Meta): model = Interface - fields = ('parent', 'name', 'untagged', 'actions') + fields = ('device', 'name', 'tagged', 'actions') + + +class VLANVirtualMachinesTable(VLANMembersTable): + virtual_machine = tables.LinkColumn() + + class Meta(BaseTable.Meta): + model = VMInterface + fields = ('virtual_machine', 'name', 'tagged', 'actions') class InterfaceVLANTable(BaseTable): diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index b2080c0a8..533335816 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -90,7 +90,8 @@ urlpatterns = [ path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', views.VLANView.as_view(), name='vlan'), - path('vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path('vlans//interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'), + path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8ea33764c..c3eac0fd7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView): class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside' + 'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object' ) filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm @@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView): def dispatch(self, request, *args, **kwargs): # Redirect user if an interface has not been provided - if 'interface' not in request.GET: + if 'interface' not in request.GET and 'vminterface' not in request.GET: return redirect('ipam:ipaddress_add') return super().dispatch(request, *args, **kwargs) @@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView): return render(request, 'ipam/ipaddress_assign.html', { 'form': form, 'table': table, - 'return_url': request.GET.get('return_url', ''), + 'return_url': request.GET.get('return_url'), }) @@ -749,15 +749,13 @@ class VLANView(ObjectView): }) -class VLANMembersView(ObjectView): +class VLANInterfacesView(ObjectView): queryset = VLAN.objects.all() def get(self, request, pk): - vlan = get_object_or_404(self.queryset, pk=pk) - members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') - - members_table = tables.VLANMemberTable(members) + interfaces = vlan.get_interfaces().prefetch_related('device') + members_table = tables.VLANDevicesTable(interfaces) paginate = { 'paginator_class': EnhancedPaginator, @@ -765,10 +763,31 @@ class VLANMembersView(ObjectView): } RequestConfig(request, paginate).configure(members_table) - return render(request, 'ipam/vlan_members.html', { + return render(request, 'ipam/vlan_interfaces.html', { 'vlan': vlan, 'members_table': members_table, - 'active_tab': 'members', + 'active_tab': 'interfaces', + }) + + +class VLANVMInterfacesView(ObjectView): + queryset = VLAN.objects.all() + + def get(self, request, pk): + vlan = get_object_or_404(self.queryset, pk=pk) + interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine') + members_table = tables.VLANVirtualMachinesTable(interfaces) + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(members_table) + + return render(request, 'ipam/vlan_vminterfaces.html', { + 'vlan': vlan, + 'members_table': members_table, + 'active_tab': 'vminterfaces', }) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index e753dd637..51c73bccc 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -33,7 +33,6 @@ REDIS = { # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -44,7 +43,6 @@ REDIS = { # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } @@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' +# Maximum execution time for background tasks, in seconds. +RQ_DEFAULT_TIMEOUT = 300 + # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' diff --git a/netbox/netbox/configuration.testing.py b/netbox/netbox/configuration.testing.py index 09d5362ab..066f94841 100644 --- a/netbox/netbox/configuration.testing.py +++ b/netbox/netbox/configuration.testing.py @@ -24,7 +24,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -32,7 +31,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6a836ff74..148bf8aeb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.2-dev' +VERSION = '2.9.4' # Hostname HOSTNAME = platform.node() @@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') +RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') @@ -220,10 +221,13 @@ TASKS_REDIS_USING_SENTINEL = all([ len(TASKS_REDIS_SENTINELS) > 0 ]) TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') +TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10) TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) -TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) +# TODO: Remove in v2.10 (see #5171) +if 'DEFAULT_TIMEOUT' in TASKS_REDIS: + warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.') # Caching if 'caching' not in REDIS: @@ -241,7 +245,6 @@ CACHING_REDIS_USING_SENTINEL = all([ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) -CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) @@ -549,7 +552,7 @@ if TASKS_REDIS_USING_SENTINEL: 'PASSWORD': TASKS_REDIS_PASSWORD, 'SOCKET_TIMEOUT': None, 'CONNECTION_KWARGS': { - 'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT + 'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT }, } else: @@ -558,8 +561,8 @@ else: 'PORT': TASKS_REDIS_PORT, 'DB': TASKS_REDIS_DATABASE, 'PASSWORD': TASKS_REDIS_PASSWORD, - 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, 'SSL': TASKS_REDIS_SSL, + 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, } RQ_QUEUES = { diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index e97893c30..34d28179b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -11,11 +11,8 @@