Merge branch 'master' into develop

This commit is contained in:
delkyd 2020-09-25 16:32:51 +08:00
commit ce03b62386
65 changed files with 687 additions and 198 deletions

View File

@ -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 ## SCRIPTS_ROOT
Default: `$INSTALL_ROOT/netbox/scripts/` Default: `$INSTALL_ROOT/netbox/scripts/`

View File

@ -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) * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set) * `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID * `DATABASE` - Numeric database ID
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis * `SSL` - Use SSL connection to Redis
An example configuration is provided below: An example configuration is provided below:
@ -77,7 +76,6 @@ REDIS = {
'PORT': 1234, 'PORT': 1234,
'PASSWORD': 'foobar', 'PASSWORD': 'foobar',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -85,7 +83,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, '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 * `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 of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to * `SENTINEL_SERVICE`: Name of the master / service to connect to
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
Example: Example:
@ -117,9 +115,9 @@ REDIS = {
'tasks': { 'tasks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'SENTINEL_TIMEOUT': 10,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -130,7 +128,6 @@ REDIS = {
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -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: Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight ```no-highlight
# pip install --upgrade pip # pip3 install --upgrade pip
``` ```
## Download NetBox ## Download NetBox
@ -163,7 +163,6 @@ REDIS = {
'PORT': 6379, # Redis port 'PORT': 6379, # Redis port
'PASSWORD': '', # Redis password (optional) 'PASSWORD': '', # Redis password (optional)
'DATABASE': 0, # Database ID 'DATABASE': 0, # Database ID
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
'SSL': False, # Use SSL (optional) 'SSL': False, # Use SSL (optional)
}, },
'caching': { 'caching': {
@ -171,7 +170,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, # Unique ID for second database 'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -1,3 +1,3 @@
## Rear Port Templates ## 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).

View File

@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes:
* `color` - One of the choices provided by `ButtonColorChoices` (optional) * `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (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 ## 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: 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:

View File

@ -1,6 +1,68 @@
# NetBox v2.9 # 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 ### 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 * [#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 * [#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 * [#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 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.) * 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}`. * `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 ### REST API Changes

View File

@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
class PortTypeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet):
TYPE_8P8C = '8p8c' TYPE_8P8C = '8p8c'
TYPE_8P6C = '8p6c'
TYPE_8P4C = '8p4c'
TYPE_8P2C = '8p2c'
TYPE_110_PUNCH = '110-punch' TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc' TYPE_BNC = 'bnc'
TYPE_MRJ21 = 'mrj21' TYPE_MRJ21 = 'mrj21'
@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
'Copper', 'Copper',
( (
(TYPE_8P8C, '8P8C'), (TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'),
(TYPE_8P4C, '8P4C'),
(TYPE_8P2C, '8P2C'),
(TYPE_110_PUNCH, '110 Punch'), (TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'), (TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'), (TYPE_MRJ21, 'MRJ21'),

View File

@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
# #
REARPORT_POSITIONS_MIN = 1 REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64 REARPORT_POSITIONS_MAX = 1024
# #

View File

@ -94,8 +94,12 @@ class RackElevationSVG:
# Embed front device type image if one exists # Embed front device type image if one exists
if self.include_images and device.device_type.front_image: if self.include_images and device.device_type.front_image:
url = '{}{}'.format(self.base_url, device.device_type.front_image.url) image = drawing.image(
image = drawing.image(href=url, insert=start, size=end, class_='device-image') href=device.device_type.front_image.url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice') image.fit(scale='slice')
link.add(image) link.add(image)
@ -107,8 +111,12 @@ class RackElevationSVG:
# Embed rear device type image if one exists # Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image: if self.include_images and device.device_type.rear_image:
url = device.device_type.rear_image.url image = drawing.image(
image = drawing.image(href=url, insert=start, size=end, class_='device-image') href=device.device_type.rear_image.url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice') image.fit(scale='slice')
drawing.add(image) drawing.add(image)
@ -141,7 +149,7 @@ class RackElevationSVG:
unit_cursor = 0 unit_cursor = 0
for u in elevation: for u in elevation:
o = other[unit_cursor] 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['device'] = o['device']
u['height'] = 1 u['height'] = 1
unit_cursor += u.get('height', 1) unit_cursor += u.get('height', 1)

View File

@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region' 'region_id': '$region'
} }
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
}
)
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
display_field='display_name', display_field='display_name',
query_params={ query_params={
'site_id': '$site' 'site_id': '$site',
'group_id': '$rack_group',
} }
) )
position = forms.TypedChoiceField( position = forms.TypedChoiceField(
@ -2317,7 +2326,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'device', 'name', 'type', 'description', 'tags', 'device', 'name', 'label', 'type', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
@ -2390,7 +2399,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = [ fields = [
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
@ -2479,7 +2488,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),

View File

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

View File

@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
class Meta: class Meta:
@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
class Meta: class Meta:

View File

@ -809,7 +809,10 @@ class FrontPort(CableTermination, ComponentModel):
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -864,7 +867,10 @@ class RearPort(CableTermination, ComponentModel):
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)

View File

@ -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 # 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 # 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. # 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( if Device.objects.exclude(pk=self.pk).filter(
name=self.name, name=self.name,
site=self.site, site=self.site,

View File

@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %} {% endfor %}
""" """
CONNECTION_STATUS = """
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
"""
# #
# Regions # Regions
@ -706,34 +710,48 @@ class DeviceComponentTable(BaseTable):
class ConsolePortTable(DeviceComponentTable): class ConsolePortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsolePort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class ConsoleServerPortTable(DeviceComponentTable): class ConsoleServerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleserverport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class PowerPortTable(DeviceComponentTable): class PowerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:powerport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTable(DeviceComponentTable): class PowerOutletTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:poweroutlet_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerOutlet 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -753,12 +771,15 @@ class BaseInterfaceTable(BaseTable):
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
tags = TagColumn(
url_name='dcim:interface_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = Interface model = Interface
fields = ( fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', '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') default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
@ -767,18 +788,26 @@ class FrontPortTable(DeviceComponentTable):
rear_port_position = tables.Column( rear_port_position = tables.Column(
verbose_name='Position' verbose_name='Position'
) )
tags = TagColumn(
url_name='dcim:frontport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = FrontPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class RearPortTable(DeviceComponentTable): class RearPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:rearport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = RearPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@ -786,10 +815,13 @@ class DeviceBayTable(DeviceComponentTable):
installed_device = tables.Column( installed_device = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn(
url_name='dcim:devicebay_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = DeviceBay 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') default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
@ -798,12 +830,16 @@ class InventoryItemTable(DeviceComponentTable):
linkify=True linkify=True
) )
discovered = BooleanColumn() discovered = BooleanColumn()
tags = TagColumn(
url_name='dcim:inventoryitem_list'
)
cable = None # Override DeviceComponentTable
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', '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') default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
verbose_name='Console Server' verbose_name='Console Server'
) )
connected_endpoint = tables.Column( connected_endpoint = tables.Column(
linkify=True,
verbose_name='Port' verbose_name='Port'
) )
device = tables.Column( device = tables.Column(
linkify=True linkify=True
) )
name = tables.Column( name = tables.Column(
linkify=True,
verbose_name='Console Port' verbose_name='Console Port'
) )
connection_status = BooleanColumn() connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsolePort model = ConsolePort
@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable):
) )
outlet = tables.Column( outlet = tables.Column(
accessor=Accessor('_connected_poweroutlet'), accessor=Accessor('_connected_poweroutlet'),
linkify=True,
verbose_name='Outlet' verbose_name='Outlet'
) )
device = tables.Column( device = tables.Column(
linkify=True linkify=True
) )
name = tables.Column( name = tables.Column(
linkify=True,
verbose_name='Power Port' verbose_name='Power Port'
) )
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPort model = PowerPort
@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
args=[Accessor('_connected_interface__pk')], args=[Accessor('_connected_interface__pk')],
verbose_name='Interface B' verbose_name='Interface B'
) )
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface

View File

@ -169,9 +169,13 @@ class SiteView(ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), '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(), '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_groups = RackGroup.objects.add_related_count(
rack_count=Count('racks') 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() show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', { return render(request, 'dcim/site.html', {
@ -310,6 +314,11 @@ class RackElevationListView(ObjectListView):
racks = filters.RackFilterSet(request.GET, self.queryset).qs racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count() total_count = racks.count()
# Determine ordering
reverse = bool(request.GET.get('reverse', False))
if reverse:
racks = racks.reverse()
# Pagination # Pagination
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
page_number = request.GET.get('page', 1) page_number = request.GET.get('page', 1)
@ -330,6 +339,7 @@ class RackElevationListView(ObjectListView):
'paginator': paginator, 'paginator': paginator,
'page': page, 'page': page,
'total_count': total_count, 'total_count': total_count,
'reverse': reverse,
'rack_face': rack_face, 'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET), 'filter_form': forms.RackElevationFilterForm(request.GET),
}) })
@ -408,7 +418,6 @@ class RackReservationListView(ObjectListView):
filterset = filters.RackReservationFilterSet filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
action_buttons = ('export',)
class RackReservationView(ObjectView): class RackReservationView(ObjectView):
@ -1033,7 +1042,7 @@ class DeviceView(ObjectView):
) )
# Interfaces # 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('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
@ -1233,6 +1242,7 @@ class ConsolePortCreateView(ComponentCreateView):
class ConsolePortEditView(ObjectEditView): class ConsolePortEditView(ObjectEditView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_edit.html'
class ConsolePortDeleteView(ObjectDeleteView): class ConsolePortDeleteView(ObjectDeleteView):
@ -1292,6 +1302,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
class ConsoleServerPortEditView(ObjectEditView): class ConsoleServerPortEditView(ObjectEditView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_edit.html'
class ConsoleServerPortDeleteView(ObjectDeleteView): class ConsoleServerPortDeleteView(ObjectDeleteView):
@ -1351,6 +1362,7 @@ class PowerPortCreateView(ComponentCreateView):
class PowerPortEditView(ObjectEditView): class PowerPortEditView(ObjectEditView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm model_form = forms.PowerPortForm
template_name = 'dcim/device_component_edit.html'
class PowerPortDeleteView(ObjectDeleteView): class PowerPortDeleteView(ObjectDeleteView):
@ -1410,6 +1422,7 @@ class PowerOutletCreateView(ComponentCreateView):
class PowerOutletEditView(ObjectEditView): class PowerOutletEditView(ObjectEditView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_edit.html'
class PowerOutletDeleteView(ObjectDeleteView): class PowerOutletDeleteView(ObjectDeleteView):
@ -1561,6 +1574,7 @@ class FrontPortCreateView(ComponentCreateView):
class FrontPortEditView(ObjectEditView): class FrontPortEditView(ObjectEditView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
model_form = forms.FrontPortForm model_form = forms.FrontPortForm
template_name = 'dcim/device_component_edit.html'
class FrontPortDeleteView(ObjectDeleteView): class FrontPortDeleteView(ObjectDeleteView):
@ -1620,6 +1634,7 @@ class RearPortCreateView(ComponentCreateView):
class RearPortEditView(ObjectEditView): class RearPortEditView(ObjectEditView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
model_form = forms.RearPortForm model_form = forms.RearPortForm
template_name = 'dcim/device_component_edit.html'
class RearPortDeleteView(ObjectDeleteView): class RearPortDeleteView(ObjectDeleteView):
@ -1679,6 +1694,7 @@ class DeviceBayCreateView(ComponentCreateView):
class DeviceBayEditView(ObjectEditView): class DeviceBayEditView(ObjectEditView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_edit.html'
class DeviceBayDeleteView(ObjectDeleteView): class DeviceBayDeleteView(ObjectDeleteView):

View File

@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields = {} instance.custom_fields = {}
for field in custom_fields: for field in custom_fields:
value = instance.cf.get(field.name) 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 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else: else:
instance.custom_fields[field.name] = value instance.custom_fields[field.name] = value

View File

@ -101,24 +101,30 @@ class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False) tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data): def create(self, validated_data):
tags = validated_data.pop('tags', []) tags = validated_data.pop('tags', None)
instance = super().create(validated_data) instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags) return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data): def update(self, instance, validated_data):
tags = validated_data.pop('tags', []) tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging # Cache tags on instance for change logging
instance._tags = tags instance._tags = tags or []
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags) return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags): def _save_tags(self, instance, tags):
if tags: if tags:
instance.tags.set(*[t.name for t in tags]) instance.tags.set(*[t.name for t in tags])
else:
instance.tags.clear()
return instance return instance

View File

@ -140,6 +140,7 @@ class ImageAttachmentViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer serializer_class = serializers.ImageAttachmentSerializer
filterset_class = filters.ImageAttachmentFilterSet
# #

View File

@ -1,4 +1,5 @@
import django_filters import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
@ -7,7 +8,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * 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__ = ( __all__ = (
@ -17,6 +18,7 @@ __all__ = (
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'GraphFilterSet', 'GraphFilterSet',
'ImageAttachmentFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'TagFilterSet', 'TagFilterSet',
@ -104,6 +106,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
fields = ['id', 'content_type', 'name', 'template_language'] fields = ['id', 'content_type', 'name', 'template_language']
class ImageAttachmentFilterSet(BaseFilterSet):
class Meta:
model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name']
class TagFilterSet(BaseFilterSet): class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
@ -251,12 +260,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
time = django_filters.DateTimeFromToRangeFilter() 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: class Meta:
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'id', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr',
'object_repr',
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):

View File

@ -397,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
user = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,
display_field='username', display_field='username',
label='User',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/users/users/', api_url='/api/users/users/',
) )

View File

@ -1,7 +1,12 @@
import time
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone 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): class Command(BaseCommand):
@ -20,15 +25,33 @@ class Command(BaseCommand):
for report in report_list: for report in report_list:
if module_name in options['reports'] or report.full_name in options['reports']: 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( self.stdout.write(
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) "[{:%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 # Report on success/failure
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') if job_result.status == JobResultStatusChoices.STATUS_FAILED:
for test_name, attrs in report.result.data.items(): 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( self.stdout.write(
"\t{}: {} success, {} info, {} warning, {} failure".format( "\t{}: {} success, {} info, {} warning, {} failure".format(
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@ -37,6 +60,9 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) "[{:%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 # Wrap things up
self.stdout.write( self.stdout.write(

View File

@ -1,11 +1,11 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase 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.choices import *
from extras.filters import * from extras.filters import *
from extras.utils import FeatureQuery 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 tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet filterset = ConfigContextFilterSet

View File

@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
sorted([t.name for t in site.tags.all()]), sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"]) 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)

View File

@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request): def get(self, request):
@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
Display a single Report and its associated JobResult (if any). Display a single Report and its associated JobResult (if any).
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request, module, name): def get(self, request, module, name):

View File

@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related( 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 serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet filterset_class = filters.IPAddressFilterSet

View File

@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
def clean(self): def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface # Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): 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") 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. # Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') 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): 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) ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. # 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 interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
interface.parent.primary_ip4 = ipaddress interface.parent.primary_ip4 = ipaddress
else: else:
interface.primary_ip6 = ipaddress interface.parent.primary_ip6 = ipaddress
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None interface.parent.primary_ip4 = None
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: 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() interface.parent.save()
return ipaddress return ipaddress

View File

@ -726,30 +726,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
}) })
# Check for primary IP assignment that doesn't match the assigned device/VM # 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() device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device: if device:
if self.assigned_object is None: if getattr(self.assigned_object, 'device', None) != device:
raise ValidationError({ 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() vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm: if vm:
if self.assigned_object is None: if getattr(self.assigned_object, 'virtual_machine', None) != vm:
raise ValidationError({ raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
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})"
}) })
# Validate IP status selection # Validate IP status selection
@ -997,13 +985,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP[self.status] return self.STATUS_CLASS_MAP[self.status]
def get_members(self): def get_interfaces(self):
# Return all interfaces assigned to this VLAN # Return all device interfaces assigned to this VLAN
return Interface.objects.filter( return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) | Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk) Q(tagged_vlans=self.pk)
).distinct() ).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') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Service(ChangeLoggedModel, CustomFieldModel): class Service(ChangeLoggedModel, CustomFieldModel):

View File

@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn 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 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """ RIR_UTILIZATION = """
@ -67,11 +68,7 @@ IPADDRESS_LINK = """
""" """
IPADDRESS_ASSIGN_LINK = """ IPADDRESS_ASSIGN_LINK = """
{% if request.GET %} <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
{% else %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
{% endif %}
""" """
VRF_LINK = """ VRF_LINK = """
@ -103,7 +100,7 @@ VLAN_LINK = """
""" """
VLAN_PREFIXES = """ VLAN_PREFIXES = """
{% for prefix in record.prefixes.unrestricted %} {% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %} <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %} {% empty %}
&mdash; &mdash;
@ -128,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
{% endwith %} {% endwith %}
""" """
VLAN_MEMBER_UNTAGGED = """ VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == vlan.pk %} {% if record.untagged_vlan_id == vlan.pk %}
<i class="glyphicon glyphicon-ok"> <span class="text-danger"><i class="fa fa-close"></i></span>
{% else %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% endif %} {% endif %}
""" """
@ -387,15 +386,23 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=TENANT_LINK template_code=TENANT_LINK
) )
assigned = tables.BooleanColumn( assigned_object = tables.Column(
accessor='assigned_object_id', linkify=True,
verbose_name='Assigned' 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): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ( 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 = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -411,6 +418,10 @@ class IPAddressDetailTable(IPAddressTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=COL_TENANT template_code=COL_TENANT
) )
assigned = BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:ipaddress_list' url_name='ipam:ipaddress_list'
) )
@ -545,15 +556,15 @@ class VLANDetailTable(VLANTable):
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMemberTable(BaseTable): class VLANMembersTable(BaseTable):
parent = tables.LinkColumn( """
order_by=['device', 'virtual_machine'] Base table for Interface and VMInterface assignments
) """
name = tables.LinkColumn( name = tables.LinkColumn(
verbose_name='Interface' verbose_name='Interface'
) )
untagged = tables.TemplateColumn( tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_UNTAGGED, template_code=VLAN_MEMBER_TAGGED,
orderable=False orderable=False
) )
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
@ -562,9 +573,21 @@ class VLANMemberTable(BaseTable):
verbose_name='' verbose_name=''
) )
class VLANDevicesTable(VLANMembersTable):
device = tables.LinkColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface 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): class InterfaceVLANTable(BaseTable):

View File

@ -90,7 +90,8 @@ urlpatterns = [
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'), path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'), path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),

View File

@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
class IPAddressListView(ObjectListView): class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside' 'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
) )
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Redirect user if an interface has not been provided # 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 redirect('ipam:ipaddress_add')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView):
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {
'form': form, 'form': form,
'table': table, '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() queryset = VLAN.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk) vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') interfaces = vlan.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
members_table = tables.VLANMemberTable(members)
paginate = { paginate = {
'paginator_class': EnhancedPaginator, 'paginator_class': EnhancedPaginator,
@ -765,10 +763,31 @@ class VLANMembersView(ObjectView):
} }
RequestConfig(request, paginate).configure(members_table) RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_members.html', { return render(request, 'ipam/vlan_interfaces.html', {
'vlan': vlan, 'vlan': vlan,
'members_table': members_table, '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',
}) })

View File

@ -33,7 +33,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -44,7 +43,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }
@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
# this setting is derived from the installed location. # this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports' # 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 # 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. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

View File

@ -24,7 +24,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -32,7 +31,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.9.2-dev' VERSION = '2.9.4'
# Hostname # Hostname
HOSTNAME = platform.node() 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_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') 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('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') 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 len(TASKS_REDIS_SENTINELS) > 0
]) ])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') 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_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) 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) 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 # Caching
if 'caching' not in REDIS: 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_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) 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) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
@ -549,7 +552,7 @@ if TASKS_REDIS_USING_SENTINEL:
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None, 'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': { 'CONNECTION_KWARGS': {
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT 'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
}, },
} }
else: else:
@ -558,8 +561,8 @@ else:
'PORT': TASKS_REDIS_PORT, 'PORT': TASKS_REDIS_PORT,
'DB': TASKS_REDIS_DATABASE, 'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
'SSL': TASKS_REDIS_SSL, 'SSL': TASKS_REDIS_SSL,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
} }
RQ_QUEUES = { RQ_QUEUES = {

View File

@ -11,11 +11,8 @@
<div class="row noprint"> <div class="row noprint">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li> <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
{% if device.rack %} <li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% endif %}
{% if device.parent_bay %} {% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li> <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li> <li>{{ device.parent_bay }}</li>
@ -101,7 +98,7 @@
</li> </li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}"> <a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventoryitems.unrestricted.count }}</span> Inventory <span class="badge">{{ device.inventoryitems.count }}</span>
</a> </a>
</li> </li>
{% if perms.dcim.napalm_read_device %} {% if perms.dcim.napalm_read_device %}
@ -151,8 +148,10 @@
<td> <td>
{% if device.rack %} {% if device.rack %}
{% if device.rack.group %} {% if device.rack.group %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> {% for group in device.rack.group.get_ancestors %}
<i class="fa fa-angle-right"></i> <a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> <i class="fa fa-caret-right"></i>
{% endif %} {% endif %}
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a> <a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
{% else %} {% else %}

View File

@ -0,0 +1,16 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form_fields %}
{% if form.instance.device %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_device">Device</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
</p>
</div>
</div>
{% endif %}
{% render_form form %}
{% endblock %}

View File

@ -23,6 +23,7 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.region %} {% render_field form.region %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.rack_group %}
{% render_field form.rack %} {% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %} {% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="form-group"> <div class="form-group">

View File

@ -66,7 +66,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleport %} {% if perms.dcim.change_consoleport %}
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs"> <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -68,7 +68,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleserverport %} {% if perms.dcim.change_consoleserverport %}
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs"> <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -52,7 +52,7 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs"> <a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -81,7 +81,7 @@
</a> </a>
{% endif %} {% endif %}
{% if perms.dcim.change_poweroutlet %} {% if perms.dcim.change_poweroutlet %}
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs"> <a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -78,7 +78,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs"> <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -5,6 +5,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <div class="panel-body">
{% if form.instance.device %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_device">Device</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
</p>
</div>
</div>
{% endif %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.label %} {% render_field form.label %}
{% render_field form.type %} {% render_field form.type %}
@ -14,6 +24,11 @@
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.mgmt_only %} {% render_field form.mgmt_only %}
{% render_field form.description %} {% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% render_field form.tagged_vlans %}

View File

@ -11,6 +11,12 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li> <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li> <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
{% if rack.group %}
{% for group in rack.group.get_ancestors %}
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
{% endfor %}
<li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
{% endif %}
<li>{{ rack }}</li> <li>{{ rack }}</li>
</ol> </ol>
</div> </div>
@ -87,7 +93,10 @@
<td>Group</td> <td>Group</td>
<td> <td>
{% if rack.group %} {% if rack.group %}
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a> {% for group in rack.group.get_ancestors %}
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}

View File

@ -3,12 +3,18 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="btn-group pull-right noprint" role="group"> <div class="btn-toolbar pull-right noprint" role="toolbar">
<button class="btn btn-default toggle-images" selected="selected"> <button class="btn btn-default toggle-images" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button> </button>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
</div>
</div> </div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1> <h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -12,7 +12,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li> <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li> <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
{% endfor %} {% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li> <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@ -86,7 +86,7 @@
<td>Region</td> <td>Region</td>
<td> <td>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<a href="{{ region.get_absolute_url }}">{{ region }}</a> <a href="{{ region.get_absolute_url }}">{{ region }}</a>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
{% endfor %} {% endfor %}
@ -255,7 +255,7 @@
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for rg in rack_groups %} {% for rg in rack_groups %}
<tr> <tr>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td> <td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
<td>{{ rg.rack_count }}</td> <td>{{ rg.rack_count }}</td>
<td class="text-right noprint"> <td class="text-right noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">

View File

@ -5,14 +5,15 @@
A module import error occurred during this request. Common causes include the following: A module import error occurred during this request. Common causes include the following:
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required <i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the <code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
console and compare the output to the list of required packages. To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded, <i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
running. ensures that the new code is running.
</p> </p>
{% endblock %} {% endblock %}

View File

@ -276,7 +276,7 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Reports</strong> <strong>Reports</strong>
</div> </div>
{% if report_results and perms.extras.view_reportresult %} {% if report_results and perms.extras.view_report %}
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for result in report_results %} {% for result in report_results %}
<tr> <tr>
@ -285,7 +285,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% elif perms.extras.view_reportresult %} {% elif perms.extras.view_report %}
<div class="panel-body text-muted"> <div class="panel-body text-muted">
None found None found
</div> </div>

View File

@ -518,7 +518,7 @@
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}> <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a> <a href="{% url 'extras:script_list' %}">Scripts</a>
</li> </li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}> <li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>
</ul> </ul>

View File

@ -5,7 +5,8 @@
{% for section_name, menu_items in registry.plugin_menu_items.items %} {% for section_name, menu_items in registry.plugin_menu_items.items %}
<li class="dropdown-header">{{ section_name }}</li> <li class="dropdown-header">{{ section_name }}</li>
{% for menu_item in menu_items %} {% for menu_item in menu_items %}
<li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}> {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
<li>
{% if menu_item.buttons %} {% if menu_item.buttons %}
<div class="buttons pull-right"> <div class="buttons pull-right">
{% for button in menu_item.buttons %} {% for button in menu_item.buttons %}
@ -17,6 +18,9 @@
{% endif %} {% endif %}
<a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a> <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
</li> </li>
{% else %}
<li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
{% endif %}
{% endfor %} {% endfor %}
{% if not forloop.last %} {% if not forloop.last %}
<li class="divider"></li> <li class="divider"></li>

View File

@ -52,8 +52,11 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a> <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li> </li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a> <a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

View File

@ -1,11 +1,9 @@
{% extends 'ipam/vlan.html' %} {% extends 'ipam/vlan.html' %}
{% block title %}{{ block.super }} - Members{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,9 @@
{% extends 'ipam/vlan.html' %}
{% block content %}
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
</div>
</div>
{% endblock %}

View File

@ -31,7 +31,9 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div> <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
<div class="panel-body"> <div class="panel-body">
{% block form_fields %}
{% render_form form %} {% render_form form %}
{% endblock %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -2,7 +2,7 @@
<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}"> <tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
{# Checkbox #} {# Checkbox #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" /> <input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td> </td>
@ -48,12 +48,12 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if perms.virtualization.change_interface %} {% if perms.virtualization.change_vminterface %}
<a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface"> <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if perms.virtualization.delete_interface %} {% if perms.virtualization.delete_vminterface %}
<a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface"> <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
@ -65,7 +65,7 @@
{% if ipaddresses %} {% if ipaddresses %}
<tr class="ipaddresses"> <tr class="ipaddresses">
{# Placeholder #} {# Placeholder #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<td></td> <td></td>
{% endif %} {% endif %}

View File

@ -2,7 +2,7 @@
{% load helpers %} {% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} {% block title %}Create {{ component_type }}{% endblock %}
{% block content %} {% block content %}
<form action="" method="post" class="form form-horizontal"> <form action="" method="post" class="form form-horizontal">

View File

@ -5,14 +5,34 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <div class="panel-body">
{% if form.instance.virtual_machine %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
</p>
</div>
</div>
{% endif %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.enabled %} {% render_field form.enabled %}
{% render_field form.mac_address %} {% render_field form.mac_address %}
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.description %} {% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% render_field form.tagged_vlans %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
</div> </div>

View File

@ -38,6 +38,10 @@ class LoginView(View):
def get(self, request): def get(self, request):
form = LoginForm(request) form = LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
}) })
@ -49,12 +53,6 @@ class LoginView(View):
if form.is_valid(): if form.is_valid():
logger.debug("Login form validation was successful") logger.debug("Login form validation was successful")
# Determine where to direct user after successful login
redirect_to = request.POST.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication. # last_login time upon authentication.
if settings.MAINTENANCE_MODE: if settings.MAINTENANCE_MODE:
@ -66,8 +64,7 @@ class LoginView(View):
logger.info(f"User {request.user} successfully authenticated") logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user)) messages.info(request, "Logged in as {}.".format(request.user))
logger.debug(f"Redirecting user to {redirect_to}") return self.redirect_to_next(request, logger)
return HttpResponseRedirect(redirect_to)
else: else:
logger.debug("Login form validation failed") logger.debug("Login form validation failed")
@ -76,6 +73,19 @@ class LoginView(View):
'form': form, 'form': form,
}) })
def redirect_to_next(self, request, logger):
if request.method == "POST":
redirect_to = request.POST.get('next', reverse('home'))
else:
redirect_to = request.GET.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')
logger.debug(f"Redirecting user to {redirect_to}")
return HttpResponseRedirect(redirect_to)
class LogoutView(View): class LogoutView(View):
""" """

View File

@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
key = f'data-query-param-{name}' key = f'data-query-param-{name}'
values = json.loads(self.attrs.get(key, '[]')) values = json.loads(self.attrs.get(key, '[]'))
if type(value) is list: if type(value) in (list, tuple):
values.extend([str(v) for v in value]) values.extend([str(v) for v in value])
else: else:
values.append(str(value)) values.append(str(value))

View File

@ -44,7 +44,7 @@ class BaseTable(tables.Table):
self.columns.show(name) self.columns.show(name)
else: else:
self.columns.hide(name) self.columns.hide(name)
self.sequence = columns self.sequence = [c for c in columns if c in self.base_columns]
# Always include PK and actions column, if defined on the table # Always include PK and actions column, if defined on the table
if pk: if pk:
@ -114,12 +114,12 @@ class BooleanColumn(tables.Column):
character. character.
""" """
def render(self, value): def render(self, value):
if value is True: if value:
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>' rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
elif value is False: elif value is None:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
else:
rendered = '<span class="text-muted">&mdash;</span>' rendered = '<span class="text-muted">&mdash;</span>'
else:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
return mark_safe(rendered) return mark_safe(rendered)

View File

@ -266,7 +266,7 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header) response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db() instance.refresh_from_db()
self.assertInstanceEqual(instance, self.update_data, api=True) self.assertInstanceEqual(instance, update_data, api=True)
class DeleteObjectViewTestCase(APITestCase): class DeleteObjectViewTestCase(APITestCase):

View File

@ -945,7 +945,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# ManyToManyFields # ManyToManyFields
elif isinstance(model_field, ManyToManyField): elif isinstance(model_field, ManyToManyField):
if form.cleaned_data[name].count() > 0: if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name]) getattr(obj, name).set(form.cleaned_data[name])
# Normal fields # Normal fields
elif form.cleaned_data[name] not in (None, ''): elif form.cleaned_data[name] not in (None, ''):

View File

@ -684,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm):
return self.cleaned_data['enabled'] return self.cleaned_data['enabled']
class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm): class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()

View File

@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0082_3569_interface_fields'),
('ipam', '0037_ipaddress_assignment'), ('ipam', '0037_ipaddress_assignment'),
('virtualization', '0015_vminterface'), ('virtualization', '0015_vminterface'),
] ]

View File

@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn(
url_name='virtualization:vminterface_list'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VMInterface model = VMInterface
fields = ( fields = (
'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'ip_addresses', 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'untagged_vlan', 'tagged_vlans',
) )
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description') default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')