mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-28 08:07:45 -06:00
Compare commits
95 Commits
v2.9-beta2
...
v2.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5aa69321 | ||
|
|
f3e4911c68 | ||
|
|
e8e4ff4111 | ||
|
|
523c32b8af | ||
|
|
5cdccb47f4 | ||
|
|
fa73bf8e87 | ||
|
|
5fe4e6cc96 | ||
|
|
f23900fc8c | ||
|
|
a0790e9119 | ||
|
|
236db7d42d | ||
|
|
5da7590eea | ||
|
|
df97eb2f72 | ||
|
|
32a0e519ad | ||
|
|
78d6561e39 | ||
|
|
9147823305 | ||
|
|
e7cf87be97 | ||
|
|
6e28490b84 | ||
|
|
fcc15d2e33 | ||
|
|
3522eafd2c | ||
|
|
848cfeb353 | ||
|
|
35a280eb31 | ||
|
|
aedba0e8be | ||
|
|
728088f5fa | ||
|
|
2116b928b6 | ||
|
|
f37997ac54 | ||
|
|
ed65603632 | ||
|
|
802af06c0f | ||
|
|
e02590ac96 | ||
|
|
7b05a18173 | ||
|
|
b22995a6d7 | ||
|
|
6c1436174c | ||
|
|
b2aa9b82c8 | ||
|
|
23aae52992 | ||
|
|
8b5e701ba4 | ||
|
|
8f88d2afab | ||
|
|
8d351178ac | ||
|
|
bc0e6cc8dd | ||
|
|
bf4fee1592 | ||
|
|
e1cf27a3ac | ||
|
|
db5bb8e5bb | ||
|
|
3ebef04a11 | ||
|
|
0d9fc309d5 | ||
|
|
c9c79dabef | ||
|
|
5fad6a63ca | ||
|
|
82900fb65d | ||
|
|
046272ff37 | ||
|
|
bc5f800a8b | ||
|
|
36d86e6220 | ||
|
|
986ef2b8e6 | ||
|
|
5629124755 | ||
|
|
0bfb64dc09 | ||
|
|
c482dcd8cb | ||
|
|
881cab051b | ||
|
|
afebf525d1 | ||
|
|
0e5d0a43f9 | ||
|
|
cf086cd7b2 | ||
|
|
81c72739b5 | ||
|
|
ff5a3c1055 | ||
|
|
bc04543b33 | ||
|
|
dd707c97af | ||
|
|
34708a8fa5 | ||
|
|
4ee8e473eb | ||
|
|
b4299241fe | ||
|
|
66c91484f5 | ||
|
|
808d621eda | ||
|
|
943c2230ba | ||
|
|
2ce99929e2 | ||
|
|
f1e82a3647 | ||
|
|
1c5af01a82 | ||
|
|
bac3ace8fc | ||
|
|
68599351aa | ||
|
|
86755029ef | ||
|
|
c507ab30e9 | ||
|
|
7d1614b933 | ||
|
|
a77d1e502c | ||
|
|
d79ed76d80 | ||
|
|
ccf8059452 | ||
|
|
3d3d1bc623 | ||
|
|
e66d065b6d | ||
|
|
c1ef87e009 | ||
|
|
3c249a40a0 | ||
|
|
5092641157 | ||
|
|
472a45ddec | ||
|
|
120cbb0159 | ||
|
|
68fbd9b017 | ||
|
|
3143f75a38 | ||
|
|
e13d4ffe60 | ||
|
|
295d4f0394 | ||
|
|
ea91e09a1b | ||
|
|
946779000f | ||
|
|
5c07b6dc1d | ||
|
|
25c3c1b431 | ||
|
|
a0ae7a227d | ||
|
|
50df3acd26 | ||
|
|
425670f52a |
@@ -4,8 +4,15 @@
|
||||
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
|
||||
|
||||
!!! note
|
||||
Beginning with version 2.8, NetBox requires Python 3.6 or later.
|
||||
## Update Dependencies to Required Versions
|
||||
|
||||
NetBox v2.9.0 and later requires the following:
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|------------|-----------------|
|
||||
| Python | 3.6 |
|
||||
| PostgreSQL | 9.6 |
|
||||
| Redis | 4.0 |
|
||||
|
||||
## Install the Latest Code
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con
|
||||
|
||||
Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
|
||||
|
||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||
|
||||
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role
|
||||
* Reserved
|
||||
* Deprecated
|
||||
* DHCP
|
||||
* SLAAC (IPv6 Stateless Address Autoconfiguration)
|
||||
|
||||
Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
# NetBox v2.8
|
||||
|
||||
## v2.8.10 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4885](https://github.com/netbox-community/netbox/issues/4885) - Add MultiChoiceVar for custom scripts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4992](https://github.com/netbox-community/netbox/issues/4992) - Add `display_name` to nested VRF serializer
|
||||
* [#4993](https://github.com/netbox-community/netbox/issues/4993) - Add `cable` to nested CircuitTermination serializer
|
||||
|
||||
---
|
||||
|
||||
## v2.8.9 (2020-08-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,51 +1,43 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9-beta2 (2020-08-13)
|
||||
|
||||
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
|
||||
## v2.9.2 (2020-08-27)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list
|
||||
* [#4919](https://github.com/netbox-community/netbox/issues/4919) - Allow adding/changing assigned permissions within group and user admin views
|
||||
* [#4922](https://github.com/netbox-community/netbox/issues/4922) - Optimize schema migration for replicating VM interfaces
|
||||
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
|
||||
* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
|
||||
* [#4946](https://github.com/netbox-community/netbox/issues/4946) - Extend ObjectPermission to OR multiple constraints
|
||||
* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions
|
||||
* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query
|
||||
* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer
|
||||
* [#4996](https://github.com/netbox-community/netbox/issues/4996) - Add "connect" buttons to individual device component views
|
||||
* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically
|
||||
* [#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
|
||||
|
||||
* [#4903](https://github.com/netbox-community/netbox/issues/4903) - Fix member count when searching for virtual chassis
|
||||
* [#4905](https://github.com/netbox-community/netbox/issues/4905) - Fix front port count on device type view
|
||||
* [#4912](https://github.com/netbox-community/netbox/issues/4912) - Fix image attachment API endpoint
|
||||
* [#4914](https://github.com/netbox-community/netbox/issues/4914) - Fix toggling cable status under device view
|
||||
* [#4921](https://github.com/netbox-community/netbox/issues/4921) - Render non-viewable devices as unavailable space in rack elevations
|
||||
* [#4930](https://github.com/netbox-community/netbox/issues/4930) - Replicate label values when instantiating device type components
|
||||
* [#4931](https://github.com/netbox-community/netbox/issues/4931) - Fix DoesNotExist exception when deleting devices
|
||||
* [#4938](https://github.com/netbox-community/netbox/issues/4938) - Show add, import buttons on virtual chassis list view
|
||||
* [#4939](https://github.com/netbox-community/netbox/issues/4939) - Fix linking to LAG interfaces on other VC members
|
||||
* [#4950](https://github.com/netbox-community/netbox/issues/4950) - Include inventory item label in API serializer, UI view
|
||||
* [#4951](https://github.com/netbox-community/netbox/issues/4951) - Redirect to device inventory view after creating a new inventory item
|
||||
* [#4952](https://github.com/netbox-community/netbox/issues/4952) - Default to VM tab when creating/editing an IP address for a VM
|
||||
* [#4968](https://github.com/netbox-community/netbox/issues/4968) - Fix exception when activating user keys in admin UI
|
||||
* [#4995](https://github.com/netbox-community/netbox/issues/4995) - Fix missing buttons to add console/power ports under device view
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
|
||||
* [#4942](https://github.com/netbox-community/netbox/issues/4942) - Make ObjectPermission's `name` field required
|
||||
* [#4943](https://github.com/netbox-community/netbox/issues/4943) - Add a `description` field to ObjectPermission
|
||||
* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
|
||||
* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
|
||||
* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
|
||||
* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
|
||||
* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
|
||||
* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
|
||||
* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
|
||||
* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
|
||||
* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
|
||||
* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
|
||||
* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
|
||||
* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
|
||||
|
||||
---
|
||||
|
||||
## v2.9-beta1 (2020-07-23)
|
||||
## v2.9.1 (2020-08-22)
|
||||
|
||||
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
|
||||
### Enhancements
|
||||
|
||||
* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC
|
||||
* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces
|
||||
* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page
|
||||
* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter
|
||||
|
||||
---
|
||||
|
||||
## v2.9.0 (2020-08-21)
|
||||
|
||||
**Note:** Redis 4.0 or later is required for this release.
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -57,11 +49,29 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
|
||||
|
||||
When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.)
|
||||
|
||||
#### Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018))
|
||||
|
||||
The VirtualChassis model now has a mandatory `name` field. Names are assigned to the virtual chassis itself rather than referencing the master VC member. Additionally, the designation of a master is now optional: a virtual chassis may have only non-master members.
|
||||
|
||||
#### Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703))
|
||||
|
||||
Tags are no longer created automatically: A tag must be created by a user before it can be applied to any object. Additionally, the REST API representation of assigned tags has been expanded to be consistent with other objects.
|
||||
|
||||
#### Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721))
|
||||
|
||||
A new model has been introduced to represent virtual machine interfaces. Although this change is largely transparent to the end user, note that the IP address model no longer has a foreign key to the Interface model under the DCIM app. This has been replaced with a generic foreign key named `assigned_object`.
|
||||
|
||||
#### REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877))
|
||||
|
||||
Two new REST API endpoints have been added to facilitate the retrieval and manipulation of users and groups:
|
||||
|
||||
* `/api/users/groups/`
|
||||
* `/api/users/users/`
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model
|
||||
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
|
||||
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates
|
||||
* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list
|
||||
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
|
||||
* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
|
||||
* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
|
||||
@@ -72,11 +82,19 @@ When running a report or custom script, its execution is now queued for backgrou
|
||||
* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters
|
||||
* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions)
|
||||
* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts
|
||||
* [#4877](https://github.com/netbox-community/netbox/issues/4877) - Add REST API endpoints for users and groups
|
||||
* [#4885](https://github.com/netbox-community/netbox/issues/4885) - Add MultiChoiceVar for custom scripts
|
||||
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
|
||||
* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
|
||||
* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions
|
||||
* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query
|
||||
* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer
|
||||
* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically
|
||||
* [#5024](https://github.com/netbox-community/netbox/issues/5024) - List available options for choice fields within CSV import forms
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
|
||||
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
|
||||
### REST API Changes
|
||||
@@ -94,6 +112,7 @@ When running a report or custom script, its execution is now queued for backgrou
|
||||
```
|
||||
|
||||
* Legacy numeric values for choice fields are no longer conveyed or accepted.
|
||||
* circuits.CircuitTermination: Added `cable` field
|
||||
* dcim.Cable: Added `tags` field
|
||||
* dcim.ConsolePort: Added `label` field
|
||||
* dcim.ConsolePortTemplate: Added `description` and `label` fields
|
||||
@@ -124,6 +143,7 @@ When running a report or custom script, its execution is now queued for backgrou
|
||||
* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult.
|
||||
* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
|
||||
* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
|
||||
* ipam.VRF: Added `display_name`
|
||||
* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree.
|
||||
* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint
|
||||
* virtualization.VMInterface: Removed `type` field (VM interfaces have no type)
|
||||
|
||||
@@ -76,6 +76,7 @@ nav:
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Version 2.9: 'release-notes/version-2.9.md'
|
||||
- Version 2.8: 'release-notes/version-2.8.md'
|
||||
- Version 2.7: 'release-notes/version-2.7.md'
|
||||
- Version 2.6: 'release-notes/version-2.6.md'
|
||||
|
||||
@@ -94,8 +94,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
url = '{}{}'.format(self.base_url, device.device_type.front_image.url)
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image = drawing.image(
|
||||
href=device.device_type.front_image.url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
|
||||
@@ -107,8 +111,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
url = device.device_type.rear_image.url
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image = drawing.image(
|
||||
href=device.device_type.rear_image.url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
|
||||
|
||||
@@ -1811,7 +1811,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
nat_inside__assigned_object_id__in=interface_ids
|
||||
).prefetch_related('assigned_object')
|
||||
if nat_ips:
|
||||
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
|
||||
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
@@ -2682,11 +2682,14 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
else:
|
||||
device = self.instance.device
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
# Limit LAG choices to interfaces belonging to this device or a peer VC member
|
||||
device_query = Q(device=device)
|
||||
if device.virtual_chassis:
|
||||
device_query |= Q(device__virtual_chassis=device.virtual_chassis)
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()],
|
||||
device_query,
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
).exclude(pk=self.instance.pk)
|
||||
|
||||
# Add current site to VLANs query params
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
@@ -2754,14 +2757,14 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||
# Limit LAG choices to interfaces belonging to this device or a peer VC member
|
||||
device = Device.objects.get(
|
||||
pk=self.initial.get('device') or self.data.get('device')
|
||||
)
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()],
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
device_query = Q(device=device)
|
||||
if device.virtual_chassis:
|
||||
device_query |= Q(device__virtual_chassis=device.virtual_chassis)
|
||||
self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
|
||||
|
||||
# Add current site to VLANs query params
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
@@ -2876,17 +2879,22 @@ class InterfaceCSVForm(CSVModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
# Limit LAG choices to interfaces belonging to this device (or virtual chassis)
|
||||
device = None
|
||||
if self.is_bound and 'device' in self.data:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
pass
|
||||
|
||||
if device:
|
||||
if device and device.virtual_chassis:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
|
||||
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
elif device:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device=device,
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
@@ -3928,6 +3936,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
|
||||
members = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'rack_id': '$rack',
|
||||
|
||||
17
netbox/dcim/migrations/0115_rackreservation_order.py
Normal file
17
netbox/dcim/migrations/0115_rackreservation_order.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1 on 2020-08-24 16:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0114_update_jsonfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='rackreservation',
|
||||
options={'ordering': ['created', 'pk']},
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ from utilities.utils import serialize_object
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'CableTermination',
|
||||
'ConsolePort',
|
||||
'ConsoleServerPort',
|
||||
@@ -688,27 +689,25 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
"Disconnect the interface or choose a suitable type."
|
||||
})
|
||||
|
||||
# An interface's LAG must belong to the same device (or VC master)
|
||||
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
|
||||
raise ValidationError({
|
||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||
self.lag.name, self.lag.device.name
|
||||
)
|
||||
})
|
||||
# An interface's LAG must belong to the same device or virtual chassis
|
||||
if self.lag and self.lag.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
raise ValidationError({
|
||||
'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
|
||||
})
|
||||
elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
|
||||
raise ValidationError({
|
||||
'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
|
||||
f"of virtual chassis {self.device.virtual_chassis}."
|
||||
})
|
||||
|
||||
# A virtual interface cannot have a parent LAG
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
|
||||
raise ValidationError({
|
||||
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
|
||||
})
|
||||
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
|
||||
raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
|
||||
|
||||
# Only a LAG can have LAG members
|
||||
if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
|
||||
raise ValidationError({
|
||||
'type': "Cannot change interface type; it has LAG members ({}).".format(
|
||||
", ".join([iface.name for iface in self.member_interfaces.all()])
|
||||
)
|
||||
})
|
||||
# A LAG interface cannot be its own parent
|
||||
if self.pk and self.lag_id == self.pk:
|
||||
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||
|
||||
1247
netbox/dcim/models/devices.py
Normal file
1247
netbox/dcim/models/devices.py
Normal file
File diff suppressed because it is too large
Load Diff
237
netbox/dcim/models/power.py
Normal file
237
netbox/dcim/models/power.py
Normal file
@@ -0,0 +1,237 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import CableTermination
|
||||
|
||||
__all__ = (
|
||||
'PowerFeed',
|
||||
'PowerPanel',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Power
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
site = models.ForeignKey(
|
||||
to='Site',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
rack_group = models.ForeignKey(
|
||||
to='RackGroup',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['site', 'rack_group', 'name']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = ['site', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerpanel', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site.name,
|
||||
self.rack_group.name if self.rack_group else None,
|
||||
self.name,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# RackGroup must belong to assigned Site
|
||||
if self.rack_group and self.rack_group.site != self.site:
|
||||
raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
|
||||
self.rack_group, self.rack_group.site, self.site
|
||||
))
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
power_panel = models.ForeignKey(
|
||||
to='PowerPanel',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='powerfeeds'
|
||||
)
|
||||
rack = models.ForeignKey(
|
||||
to='Rack',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connected_endpoint = models.OneToOneField(
|
||||
to='dcim.PowerPort',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerFeedStatusChoices,
|
||||
default=PowerFeedStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerFeedTypeChoices,
|
||||
default=PowerFeedTypeChoices.TYPE_PRIMARY
|
||||
)
|
||||
supply = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerFeedSupplyChoices,
|
||||
default=PowerFeedSupplyChoices.SUPPLY_AC
|
||||
)
|
||||
phase = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerFeedPhaseChoices,
|
||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||
)
|
||||
voltage = models.SmallIntegerField(
|
||||
default=POWERFEED_VOLTAGE_DEFAULT,
|
||||
validators=[ExclusionValidator([0])]
|
||||
)
|
||||
amperage = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1)],
|
||||
default=POWERFEED_AMPERAGE_DEFAULT
|
||||
)
|
||||
max_utilization = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
|
||||
help_text="Maximum permissible draw (percentage)"
|
||||
)
|
||||
available_power = models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
|
||||
'amperage', 'max_utilization', 'comments',
|
||||
]
|
||||
clone_fields = [
|
||||
'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
|
||||
'available_power',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
|
||||
PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
|
||||
PowerFeedStatusChoices.STATUS_PLANNED: 'info',
|
||||
PowerFeedStatusChoices.STATUS_FAILED: 'danger',
|
||||
}
|
||||
|
||||
TYPE_CLASS_MAP = {
|
||||
PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
|
||||
PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
unique_together = ['power_panel', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerfeed', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.power_panel.site.name,
|
||||
self.power_panel.name,
|
||||
self.rack.group.name if self.rack and self.rack.group else None,
|
||||
self.rack.name if self.rack else None,
|
||||
self.name,
|
||||
self.get_status_display(),
|
||||
self.get_type_display(),
|
||||
self.get_supply_display(),
|
||||
self.get_phase_display(),
|
||||
self.voltage,
|
||||
self.amperage,
|
||||
self.max_utilization,
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
))
|
||||
|
||||
# AC voltage cannot be negative
|
||||
if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
|
||||
raise ValidationError({
|
||||
"voltage": "Voltage cannot be negative for AC supply"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Cache the available_power property on the instance
|
||||
kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
|
||||
if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
|
||||
self.available_power = round(kva * 1.732)
|
||||
else:
|
||||
self.available_power = round(kva)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.power_panel
|
||||
|
||||
def get_type_class(self):
|
||||
return self.TYPE_CLASS_MAP.get(self.type)
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
655
netbox/dcim/models/racks.py
Normal file
655
netbox/dcim/models/racks.py
Normal file
@@ -0,0 +1,655 @@
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Sum
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.utils import serialize_object
|
||||
from .devices import Device
|
||||
from .power import PowerFeed
|
||||
|
||||
__all__ = (
|
||||
'Rack',
|
||||
'RackGroup',
|
||||
'RackReservation',
|
||||
'RackRole',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
@extras_features('export_templates')
|
||||
class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
"""
|
||||
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
|
||||
example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
|
||||
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rack_groups'
|
||||
)
|
||||
parent = TreeForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='children',
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = [
|
||||
['site', 'name'],
|
||||
['site', 'slug'],
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site,
|
||||
self.parent.name if self.parent else '',
|
||||
self.name,
|
||||
self.slug,
|
||||
self.description,
|
||||
)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
# Remove MPTT-internal fields
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Parent RackGroup (if any) must belong to the same Site
|
||||
if self.parent and self.parent.site != self.site:
|
||||
raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
|
||||
|
||||
|
||||
class RackRole(ChangeLoggedModel):
|
||||
"""
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'color', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.color,
|
||||
self.description,
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
facility_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Facility ID',
|
||||
help_text='Locally-assigned identifier'
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='racks'
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
to='dcim.RackGroup',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='racks',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Assigned group'
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='racks',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=RackStatusChoices,
|
||||
default=RackStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
to='dcim.RackRole',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='racks',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Functional role'
|
||||
)
|
||||
serial = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='Serial number'
|
||||
)
|
||||
asset_tag = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name='Asset tag',
|
||||
help_text='A unique tag used to identify this rack'
|
||||
)
|
||||
type = models.CharField(
|
||||
choices=RackTypeChoices,
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='Type'
|
||||
)
|
||||
width = models.PositiveSmallIntegerField(
|
||||
choices=RackWidthChoices,
|
||||
default=RackWidthChoices.WIDTH_19IN,
|
||||
verbose_name='Width',
|
||||
help_text='Rail-to-rail width'
|
||||
)
|
||||
u_height = models.PositiveSmallIntegerField(
|
||||
default=RACK_U_HEIGHT_DEFAULT,
|
||||
verbose_name='Height (U)',
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
help_text='Height in rack units'
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Descending units',
|
||||
help_text='Units are numbered top-to-bottom'
|
||||
)
|
||||
outer_width = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Outer dimension of rack (width)'
|
||||
)
|
||||
outer_depth = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Outer dimension of rack (depth)'
|
||||
)
|
||||
outer_unit = models.CharField(
|
||||
max_length=50,
|
||||
choices=RackDimensionUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
|
||||
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
|
||||
]
|
||||
clone_fields = [
|
||||
'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
RackStatusChoices.STATUS_RESERVED: 'warning',
|
||||
RackStatusChoices.STATUS_AVAILABLE: 'success',
|
||||
RackStatusChoices.STATUS_PLANNED: 'info',
|
||||
RackStatusChoices.STATUS_ACTIVE: 'primary',
|
||||
RackStatusChoices.STATUS_DEPRECATED: 'danger',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
|
||||
unique_together = (
|
||||
# Name and facility_id must be unique *only* within a RackGroup
|
||||
('group', 'name'),
|
||||
('group', 'facility_id'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name or super().__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
elif self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
if self.pk:
|
||||
# Validate that Rack is tall enough to house the installed Devices
|
||||
top_device = Device.objects.filter(
|
||||
rack=self
|
||||
).exclude(
|
||||
position__isnull=True
|
||||
).order_by('-position').first()
|
||||
if top_device:
|
||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
||||
if self.u_height < min_height:
|
||||
raise ValidationError({
|
||||
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
|
||||
min_height
|
||||
)
|
||||
})
|
||||
# Validate that Rack was assigned a group of its same site, if applicable
|
||||
if self.group:
|
||||
if self.group.site != self.site:
|
||||
raise ValidationError({
|
||||
'group': "Rack group must be from the same site, {}.".format(self.site)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Record the original site assignment for this rack.
|
||||
_site_id = None
|
||||
if self.pk:
|
||||
_site_id = Rack.objects.get(pk=self.pk).site_id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update racked devices if the assigned Site has been changed.
|
||||
if _site_id is not None and self.site_id != _site_id:
|
||||
devices = Device.objects.filter(rack=self)
|
||||
for device in devices:
|
||||
device.site = self.site
|
||||
device.save()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site.name,
|
||||
self.group.name if self.group else None,
|
||||
self.name,
|
||||
self.facility_id,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.get_status_display(),
|
||||
self.role.name if self.role else None,
|
||||
self.get_type_display() if self.type else None,
|
||||
self.serial,
|
||||
self.asset_tag,
|
||||
self.width,
|
||||
self.u_height,
|
||||
self.desc_units,
|
||||
self.outer_width,
|
||||
self.outer_depth,
|
||||
self.outer_unit,
|
||||
self.comments,
|
||||
)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
if self.desc_units:
|
||||
return range(1, self.u_height + 1)
|
||||
else:
|
||||
return reversed(range(1, self.u_height + 1))
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.facility_id:
|
||||
return f'{self.name} ({self.facility_id})'
|
||||
return self.name
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
|
||||
"""
|
||||
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
|
||||
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
|
||||
|
||||
:param face: Rack face (front or rear)
|
||||
:param user: User instance to be used for evaluating device view permissions. If None, all devices
|
||||
will be included.
|
||||
:param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
|
||||
:param expand_devices: When True, all units that a device occupies will be listed with each containing a
|
||||
reference to the device. When False, only the bottom most unit for a device is included and that unit
|
||||
contains a height attribute for the device
|
||||
"""
|
||||
|
||||
elevation = OrderedDict()
|
||||
for u in self.units:
|
||||
elevation[u] = {
|
||||
'id': u,
|
||||
'name': f'U{u}',
|
||||
'face': face,
|
||||
'device': None,
|
||||
'occupied': False
|
||||
}
|
||||
|
||||
# Add devices to rack units list
|
||||
if self.pk:
|
||||
|
||||
# Retrieve all devices installed within the rack
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type',
|
||||
'device_type__manufacturer',
|
||||
'device_role'
|
||||
).annotate(
|
||||
devicebay_count=Count('devicebays')
|
||||
).exclude(
|
||||
pk=exclude
|
||||
).filter(
|
||||
rack=self,
|
||||
position__gt=0,
|
||||
device_type__u_height__gt=0
|
||||
).filter(
|
||||
Q(face=face) | Q(device_type__is_full_depth=True)
|
||||
)
|
||||
|
||||
# Determine which devices the user has permission to view
|
||||
permitted_device_ids = []
|
||||
if user is not None:
|
||||
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
|
||||
|
||||
for device in queryset:
|
||||
if expand_devices:
|
||||
for u in range(device.position, device.position + device.device_type.u_height):
|
||||
if user is None or device.pk in permitted_device_ids:
|
||||
elevation[u]['device'] = device
|
||||
elevation[u]['occupied'] = True
|
||||
else:
|
||||
if user is None or device.pk in permitted_device_ids:
|
||||
elevation[device.position]['device'] = device
|
||||
elevation[device.position]['occupied'] = True
|
||||
elevation[device.position]['height'] = device.device_type.u_height
|
||||
for u in range(device.position + 1, device.position + device.device_type.u_height):
|
||||
elevation.pop(u, None)
|
||||
|
||||
return [u for u in elevation.values()]
|
||||
|
||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
|
||||
"""
|
||||
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
||||
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
||||
position to another within a rack).
|
||||
|
||||
:param u_height: Minimum number of contiguous free units required
|
||||
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
|
||||
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
|
||||
"""
|
||||
# Gather all devices which consume U space within the rack
|
||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
||||
if exclude is not None:
|
||||
devices = devices.exclude(pk__in=exclude)
|
||||
|
||||
# Initialize the rack unit skeleton
|
||||
units = list(range(1, self.u_height + 1))
|
||||
|
||||
# Remove units consumed by installed devices
|
||||
for d in devices:
|
||||
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
|
||||
for u in range(d.position, d.position + d.device_type.u_height):
|
||||
try:
|
||||
units.remove(u)
|
||||
except ValueError:
|
||||
# Found overlapping devices in the rack!
|
||||
pass
|
||||
|
||||
# Remove units without enough space above them to accommodate a device of the specified height
|
||||
available_units = []
|
||||
for u in units:
|
||||
if set(range(u, u + u_height)).issubset(units):
|
||||
available_units.append(u)
|
||||
|
||||
return list(reversed(available_units))
|
||||
|
||||
def get_reserved_units(self):
|
||||
"""
|
||||
Return a dictionary mapping all reserved units within the rack to their reservation.
|
||||
"""
|
||||
reserved_units = {}
|
||||
for r in self.reservations.all():
|
||||
for u in r.units:
|
||||
reserved_units[u] = r
|
||||
return reserved_units
|
||||
|
||||
def get_elevation_svg(
|
||||
self,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
user=None,
|
||||
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
|
||||
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
include_images=True,
|
||||
base_url=None
|
||||
):
|
||||
"""
|
||||
Return an SVG of the rack elevation
|
||||
|
||||
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
|
||||
:param user: User instance to be used for evaluating device view permissions. If None, all devices
|
||||
will be included.
|
||||
:param unit_width: Width in pixels for the rendered drawing
|
||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||
height of the elevation
|
||||
:param legend_width: Width of the unit legend, in pixels
|
||||
:param include_images: Embed front/rear device images where available
|
||||
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
||||
"""
|
||||
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
|
||||
|
||||
return elevation.render(face, unit_width, unit_height, legend_width)
|
||||
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
|
||||
def get_utilization(self):
|
||||
"""
|
||||
Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
|
||||
as utilized.
|
||||
"""
|
||||
# Determine unoccupied units
|
||||
available_units = self.get_available_units()
|
||||
|
||||
# Remove reserved units
|
||||
for u in self.get_reserved_units():
|
||||
if u in available_units:
|
||||
available_units.remove(u)
|
||||
|
||||
occupied_unit_count = self.u_height - len(available_units)
|
||||
percentage = int(float(occupied_unit_count) / self.u_height * 100)
|
||||
|
||||
return percentage
|
||||
|
||||
def get_power_utilization(self):
|
||||
"""
|
||||
Determine the utilization rate of power in the rack and return it as a percentage.
|
||||
"""
|
||||
power_stats = PowerFeed.objects.filter(
|
||||
rack=self
|
||||
).annotate(
|
||||
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
|
||||
).values(
|
||||
'allocated_draw_total',
|
||||
'available_power'
|
||||
)
|
||||
|
||||
if power_stats:
|
||||
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
|
||||
available_power_total = sum(x['available_power'] for x in power_stats)
|
||||
return int(allocated_draw_total / available_power_total * 100) or 0
|
||||
return 0
|
||||
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class RackReservation(ChangeLoggedModel):
|
||||
"""
|
||||
One or more reserved units within a Rack.
|
||||
"""
|
||||
rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reservations'
|
||||
)
|
||||
units = ArrayField(
|
||||
base_field=models.PositiveSmallIntegerField()
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rackreservations',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=User,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
if hasattr(self, 'rack') and self.units:
|
||||
|
||||
# Validate that all specified units exist in the Rack.
|
||||
invalid_units = [u for u in self.units if u not in self.rack.units]
|
||||
if invalid_units:
|
||||
raise ValidationError({
|
||||
'units': "Invalid unit(s) for {}U rack: {}".format(
|
||||
self.rack.u_height,
|
||||
', '.join([str(u) for u in invalid_units]),
|
||||
),
|
||||
})
|
||||
|
||||
# Check that none of the units has already been reserved for this Rack.
|
||||
reserved_units = []
|
||||
for resv in self.rack.reservations.exclude(pk=self.pk):
|
||||
reserved_units += resv.units
|
||||
conflicting_units = [u for u in self.units if u in reserved_units]
|
||||
if conflicting_units:
|
||||
raise ValidationError({
|
||||
'units': 'The following units have already been reserved: {}'.format(
|
||||
', '.join([str(u) for u in conflicting_units]),
|
||||
)
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.rack.site.name,
|
||||
self.rack.group if self.rack.group else None,
|
||||
self.rack.name,
|
||||
','.join([str(u) for u in self.units]),
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.user.username,
|
||||
self.description
|
||||
)
|
||||
|
||||
@property
|
||||
def unit_list(self):
|
||||
"""
|
||||
Express the assigned units as a string of summarized ranges. For example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||
"""
|
||||
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
|
||||
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
|
||||
246
netbox/dcim/models/sites.py
Normal file
246
netbox/dcim/models/sites.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import ASNField
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
__all__ = (
|
||||
'Region',
|
||||
'Site',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Region(MPTTModel, ChangeLoggedModel):
|
||||
"""
|
||||
Sites can be grouped within geographic Regions.
|
||||
"""
|
||||
parent = TreeForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='children',
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'parent', 'description']
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.parent.name if self.parent else None,
|
||||
self.description,
|
||||
)
|
||||
|
||||
def get_site_count(self):
|
||||
return Site.objects.filter(
|
||||
Q(region=self) |
|
||||
Q(region__in=self.get_descendants())
|
||||
).count()
|
||||
|
||||
def to_objectchange(self, action):
|
||||
# Remove MPTT-internal fields
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=SiteStatusChoices,
|
||||
default=SiteStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
region = models.ForeignKey(
|
||||
to='dcim.Region',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='sites',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sites',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
facility = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text='Local facility ID or description'
|
||||
)
|
||||
asn = ASNField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='ASN',
|
||||
help_text='32-bit autonomous system number'
|
||||
)
|
||||
time_zone = TimeZoneField(
|
||||
blank=True
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
physical_address = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
shipping_address = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
latitude = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='GPS coordinate (latitude)'
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='GPS coordinate (longitude)'
|
||||
)
|
||||
contact_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True
|
||||
)
|
||||
contact_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True
|
||||
)
|
||||
contact_email = models.EmailField(
|
||||
blank=True,
|
||||
verbose_name='Contact E-mail'
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
clone_fields = [
|
||||
'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
SiteStatusChoices.STATUS_PLANNED: 'info',
|
||||
SiteStatusChoices.STATUS_STAGING: 'primary',
|
||||
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
||||
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
SiteStatusChoices.STATUS_RETIRED: 'danger',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name',)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:site', args=[self.slug])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.get_status_display(),
|
||||
self.region.name if self.region else None,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.facility,
|
||||
self.asn,
|
||||
self.time_zone,
|
||||
self.description,
|
||||
self.physical_address,
|
||||
self.shipping_address,
|
||||
self.latitude,
|
||||
self.longitude,
|
||||
self.contact_name,
|
||||
self.contact_phone,
|
||||
self.contact_email,
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
@@ -706,34 +706,48 @@ class DeviceComponentTable(BaseTable):
|
||||
|
||||
|
||||
class ConsolePortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class ConsoleServerPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleserverport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class PowerPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
class PowerOutletTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:poweroutlet_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
|
||||
@@ -753,12 +767,15 @@ class BaseInterfaceTable(BaseTable):
|
||||
|
||||
|
||||
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@@ -767,18 +784,26 @@ class FrontPortTable(DeviceComponentTable):
|
||||
rear_port_position = tables.Column(
|
||||
verbose_name='Position'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:frontport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
|
||||
|
||||
|
||||
class RearPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rearport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@@ -786,10 +811,13 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
installed_device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicebay_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
|
||||
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
|
||||
|
||||
|
||||
@@ -798,12 +826,16 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
linkify=True
|
||||
)
|
||||
discovered = BooleanColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
cable = None # Override DeviceComponentTable
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered',
|
||||
'discovered', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
|
||||
|
||||
@@ -176,13 +176,12 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
custom_fields = validated_data.pop('custom_fields', None)
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
# Save custom fields
|
||||
custom_fields = validated_data.get('custom_fields')
|
||||
if custom_fields is not None:
|
||||
self._save_custom_fields(instance, custom_fields)
|
||||
instance.custom_fields = custom_fields
|
||||
@@ -191,10 +190,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
custom_fields = validated_data.pop('custom_fields', None)
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
custom_fields = validated_data.get('custom_fields')
|
||||
instance._cf = custom_fields
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
# Save custom fields
|
||||
|
||||
@@ -108,6 +108,10 @@ class TaggedObjectSerializer(serializers.Serializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
|
||||
# Cache tags on instance for change logging
|
||||
instance._tags = tags
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
|
||||
32
netbox/extras/context_managers.py
Normal file
32
netbox/extras/context_managers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||
|
||||
from extras.signals import _handle_changed_object, _handle_deleted_object
|
||||
from utilities.utils import curry
|
||||
|
||||
|
||||
@contextmanager
|
||||
def change_logging(request):
|
||||
"""
|
||||
Enable change logging by connecting the appropriate signals to their receivers before code is run, and
|
||||
disconnecting them afterward.
|
||||
|
||||
:param request: WSGIRequest object with a unique `id` set
|
||||
"""
|
||||
# Curry signals receivers to pass the current request
|
||||
handle_changed_object = curry(_handle_changed_object, request)
|
||||
handle_deleted_object = curry(_handle_deleted_object, request)
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
yield
|
||||
|
||||
# Disconnect change logging signals. This is necessary to avoid recording any errant
|
||||
# changes during test cleanup.
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
@@ -29,6 +29,9 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance._cf is None:
|
||||
self.instance._cf = {}
|
||||
|
||||
self._append_customfield_fields()
|
||||
|
||||
def _append_customfield_fields(self):
|
||||
@@ -48,9 +51,12 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
field_name = 'cf_{}'.format(cf.name)
|
||||
if self.instance.pk:
|
||||
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
||||
self.fields[field_name].initial = self.custom_field_values.get(cf.name)
|
||||
value = self.custom_field_values.get(cf.name)
|
||||
self.fields[field_name].initial = value
|
||||
self.instance._cf[cf.name] = value
|
||||
else:
|
||||
self.fields[field_name] = cf.to_form_field()
|
||||
self.instance._cf[cf.name] = self.fields[field_name].initial
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields.append(field_name)
|
||||
@@ -77,13 +83,18 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
cfv.save()
|
||||
|
||||
def save(self, commit=True):
|
||||
|
||||
# Cache custom field values on object prior to save to ensure change logging
|
||||
for cf_name in self.custom_fields:
|
||||
self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
||||
|
||||
obj = super().save(commit)
|
||||
|
||||
# Handle custom fields the same way we do M2M fields
|
||||
if commit:
|
||||
self._save_custom_fields()
|
||||
else:
|
||||
self.save_custom_fields = self._save_custom_fields
|
||||
obj.save_custom_fields = self._save_custom_fields
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
@@ -1,64 +1,6 @@
|
||||
import random
|
||||
import threading
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from extras.utils import is_taggable
|
||||
from utilities.api import is_api_request
|
||||
from utilities.querysets import DummyQuerySet
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ObjectChange
|
||||
from .signals import purge_changelog
|
||||
from .webhooks import enqueue_webhooks
|
||||
|
||||
_thread_locals = threading.local()
|
||||
|
||||
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
# Queue the object for processing once the request completes
|
||||
action = ObjectChangeActionChoices.ACTION_CREATE if kwargs['created'] else ObjectChangeActionChoices.ACTION_UPDATE
|
||||
_thread_locals.changed_objects.append(
|
||||
(instance, action)
|
||||
)
|
||||
|
||||
|
||||
def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Cache custom fields prior to copying the instance
|
||||
if hasattr(instance, 'cache_custom_fields'):
|
||||
instance.cache_custom_fields()
|
||||
|
||||
# Create a copy of the object being deleted
|
||||
copy = deepcopy(instance)
|
||||
|
||||
# Preserve tags
|
||||
if is_taggable(instance):
|
||||
copy.tags = DummyQuerySet(instance.tags.all())
|
||||
|
||||
# Queue the copy of the object for processing once the request completes
|
||||
_thread_locals.changed_objects.append(
|
||||
(copy, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
)
|
||||
|
||||
|
||||
def purge_objectchange_cache(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued object changes waiting to be written.
|
||||
"""
|
||||
_thread_locals.changed_objects = []
|
||||
from .context_managers import change_logging
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
@@ -79,74 +21,12 @@ class ObjectChangeMiddleware(object):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
|
||||
# Initialize an empty list to cache objects being saved.
|
||||
_thread_locals.changed_objects = []
|
||||
|
||||
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
|
||||
# the same request.
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
# Provide a hook for purging the change cache
|
||||
purge_changelog.connect(purge_objectchange_cache)
|
||||
|
||||
# Process the request
|
||||
response = self.get_response(request)
|
||||
|
||||
# If the change cache is empty, there's nothing more we need to do.
|
||||
if not _thread_locals.changed_objects:
|
||||
return response
|
||||
|
||||
# Disconnect our receivers from the post_save and post_delete signals.
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
# Create records for any cached objects that were changed.
|
||||
redis_failed = False
|
||||
for instance, action in _thread_locals.changed_objects:
|
||||
|
||||
# Refresh cached custom field values
|
||||
if action in [ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]:
|
||||
if hasattr(instance, 'cache_custom_fields'):
|
||||
instance.cache_custom_fields()
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
try:
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
except RedisError as e:
|
||||
if not redis_failed and not is_api_request(request):
|
||||
messages.error(
|
||||
request,
|
||||
"There was an error processing webhooks for this request. Check that the Redis service is "
|
||||
"running and reachable. The full error details were: {}".format(e)
|
||||
)
|
||||
redis_failed = True
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
model_inserts.labels(instance._meta.model_name).inc()
|
||||
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
elif action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
|
||||
# one or more changes being logged.
|
||||
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
|
||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||
purged_count, _ = ObjectChange.objects.filter(
|
||||
time__lt=cutoff
|
||||
).delete()
|
||||
# Process the request with change logging enabled
|
||||
with change_logging(request):
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
@@ -18,11 +17,14 @@ from extras.utils import FeatureQuery
|
||||
#
|
||||
|
||||
class CustomFieldModel(models.Model):
|
||||
_cf = None
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __init__(self, *args, custom_fields=None, **kwargs):
|
||||
self._cf = custom_fields
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def cache_custom_fields(self):
|
||||
"""
|
||||
Cache all custom field values for this instance
|
||||
|
||||
@@ -652,15 +652,13 @@ class JobResult(models.Model):
|
||||
|
||||
def set_status(self, status):
|
||||
"""
|
||||
Helper method to change the status of the job result and save. If the target status is terminal, the
|
||||
completion time is also set.
|
||||
Helper method to change the status of the job result. If the target status is terminal, the completion
|
||||
time is also set.
|
||||
"""
|
||||
self.status = status
|
||||
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
self.completed = timezone.now()
|
||||
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -2,10 +2,10 @@ import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import traceback
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django_rq import job
|
||||
|
||||
@@ -79,6 +79,7 @@ def run_report(job_result, *args, **kwargs):
|
||||
except Exception as e:
|
||||
print(e)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
|
||||
# Delete any previous terminal state results
|
||||
@@ -170,7 +171,7 @@ class Report(object):
|
||||
timezone.now().isoformat(),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
|
||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
message,
|
||||
))
|
||||
|
||||
@@ -223,17 +224,25 @@ class Report(object):
|
||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
try:
|
||||
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
job_result.data = self._results
|
||||
job_result.completed = timezone.now()
|
||||
|
||||
@@ -22,8 +22,8 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import change_logging
|
||||
from .forms import ScriptForm
|
||||
from .signals import purge_changelog
|
||||
|
||||
__all__ = [
|
||||
'BaseScript',
|
||||
@@ -436,41 +436,38 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
if 'commit' in inspect.signature(script.run).parameters:
|
||||
kwargs['commit'] = commit
|
||||
else:
|
||||
warnings.warn(f"The run() method of script {script} should support a 'commit' argument. This will be required "
|
||||
f"beginning with NetBox v2.10.")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(**kwargs)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
|
||||
warnings.warn(
|
||||
f"The run() method of script {script} should support a 'commit' argument. This will be required beginning "
|
||||
f"with NetBox v2.10."
|
||||
)
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
commit = False
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
finally:
|
||||
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
with change_logging(request):
|
||||
|
||||
if not commit:
|
||||
# Delete all pending changelog entries
|
||||
purge_changelog.send(Script)
|
||||
script.log_info(
|
||||
"Database changes have been reverted automatically."
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(**kwargs)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
# Delete any previous terminal state results
|
||||
JobResult.objects.filter(
|
||||
|
||||
@@ -1,7 +1,75 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from cacheops.signals import cache_invalidated, cache_read
|
||||
from django.dispatch import Signal
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from prometheus_client import Counter
|
||||
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ObjectChange
|
||||
from .webhooks import enqueue_webhooks
|
||||
|
||||
|
||||
#
|
||||
# Change logging/webhooks
|
||||
#
|
||||
|
||||
def _handle_changed_object(request, sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
# Queue the object for processing once the request completes
|
||||
if kwargs.get('created'):
|
||||
action = ObjectChangeActionChoices.ACTION_CREATE
|
||||
elif 'created' in kwargs:
|
||||
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
|
||||
# m2m_changed with objects added or removed
|
||||
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
model_inserts.labels(instance._meta.model_name).inc()
|
||||
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
|
||||
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
|
||||
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
|
||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
||||
|
||||
|
||||
def _handle_deleted_object(request, sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
#
|
||||
# Caching
|
||||
@@ -25,10 +93,3 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
|
||||
|
||||
cache_read.connect(cache_read_collector)
|
||||
cache_invalidated.connect(cache_invalidated_collector)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
purge_changelog = Signal()
|
||||
|
||||
@@ -2,13 +2,125 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldValue, ObjectChange
|
||||
from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing.utils import post_data
|
||||
from utilities.testing.views import ModelViewTestCase
|
||||
|
||||
|
||||
class ChangeLogTest(APITestCase):
|
||||
class ChangeLogViewTest(ModelViewTestCase):
|
||||
model = Site
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
# Create a custom field on the Site model
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
name='my_field',
|
||||
required=False
|
||||
)
|
||||
cf.save()
|
||||
cf.obj_type.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||
form_data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'cf_my_field': 'ABC',
|
||||
'tags': [tag.pk for tag in tags],
|
||||
}
|
||||
|
||||
request = {
|
||||
'path': self._get_url('add'),
|
||||
'data': post_data(form_data),
|
||||
}
|
||||
self.add_permissions('dcim.add_site', 'extras.view_tag')
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
site = Site.objects.get(name='Test Site 1')
|
||||
# First OC is the creation; second is the tags update
|
||||
oc_list = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
).order_by('pk')
|
||||
self.assertEqual(oc_list[0].changed_object, site)
|
||||
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
def test_update_object(self):
|
||||
site = Site(name='Test Site 1', slug='test-site-1')
|
||||
site.save()
|
||||
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
|
||||
form_data = {
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||
'cf_my_field': 'DEF',
|
||||
'tags': [tags[2].pk],
|
||||
}
|
||||
|
||||
request = {
|
||||
'path': self._get_url('edit', instance=site),
|
||||
'data': post_data(form_data),
|
||||
}
|
||||
self.add_permissions('dcim.change_site', 'extras.view_tag')
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
site.refresh_from_db()
|
||||
# Get only the most recent OC
|
||||
oc = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
).first()
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
site = Site(
|
||||
name='Test Site 1',
|
||||
slug='test-site-1'
|
||||
)
|
||||
site.save()
|
||||
self.create_tags('Tag 1', 'Tag 2')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
CustomFieldValue.objects.create(
|
||||
field=CustomField.objects.get(name='my_field'),
|
||||
obj=site,
|
||||
value='ABC'
|
||||
)
|
||||
|
||||
request = {
|
||||
'path': self._get_url('delete', instance=site),
|
||||
'data': post_data({'confirm': True}),
|
||||
}
|
||||
self.add_permissions('dcim.delete_site')
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
oc = ObjectChange.objects.first()
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
|
||||
class ChangeLogAPITest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -23,6 +135,14 @@ class ChangeLogTest(APITestCase):
|
||||
cf.save()
|
||||
cf.obj_type.set([ct])
|
||||
|
||||
# Create some tags
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
Tag(name='Tag 2', slug='tag-2'),
|
||||
Tag(name='Tag 3', slug='tag-3'),
|
||||
)
|
||||
Tag.objects.bulk_create(tags)
|
||||
|
||||
def test_create_object(self):
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
@@ -30,6 +150,10 @@ class ChangeLogTest(APITestCase):
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 1'},
|
||||
{'name': 'Tag 2'},
|
||||
]
|
||||
}
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
url = reverse('dcim-api:site-list')
|
||||
@@ -39,13 +163,16 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
# First OC is the creation; second is the tags update
|
||||
oc_list = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
).order_by('pk')
|
||||
self.assertEqual(oc_list[0].changed_object, site)
|
||||
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
def test_update_object(self):
|
||||
site = Site(name='Test Site 1', slug='test-site-1')
|
||||
@@ -57,6 +184,9 @@ class ChangeLogTest(APITestCase):
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 3'}
|
||||
]
|
||||
}
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
self.add_permissions('dcim.change_site')
|
||||
@@ -66,13 +196,15 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
# Get only the most recent OC
|
||||
oc = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
).first()
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
site = Site(
|
||||
@@ -80,6 +212,7 @@ class ChangeLogTest(APITestCase):
|
||||
slug='test-site-1'
|
||||
)
|
||||
site.save()
|
||||
site.tags.set(*Tag.objects.all()[:2])
|
||||
CustomFieldValue.objects.create(
|
||||
field=CustomField.objects.get(name='my_field'),
|
||||
obj=site,
|
||||
@@ -98,3 +231,4 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
@@ -152,10 +152,7 @@ class ScriptVariablesTest(TestCase):
|
||||
def test_objectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = ObjectVar(
|
||||
queryset=DeviceRole.objects.all()
|
||||
)
|
||||
var1 = ObjectVar(model=DeviceRole)
|
||||
|
||||
# Populate some objects
|
||||
for i in range(1, 6):
|
||||
@@ -173,10 +170,7 @@ class ScriptVariablesTest(TestCase):
|
||||
def test_multiobjectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = MultiObjectVar(
|
||||
queryset=DeviceRole.objects.all()
|
||||
)
|
||||
var1 = MultiObjectVar(model=DeviceRole)
|
||||
|
||||
# Populate some objects
|
||||
for i in range(1, 6):
|
||||
|
||||
@@ -3,7 +3,6 @@ import collections
|
||||
from django.db.models import Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from taggit.managers import _TaggableManager
|
||||
from utilities.querysets import DummyQuerySet
|
||||
|
||||
from extras.constants import EXTRAS_FEATURES
|
||||
from extras.registry import registry
|
||||
@@ -16,9 +15,6 @@ def is_taggable(obj):
|
||||
if hasattr(obj, 'tags'):
|
||||
if issubclass(obj.tags.__class__, _TaggableManager):
|
||||
return True
|
||||
# TaggableManager has been replaced with a DummyQuerySet prior to object deletion
|
||||
if isinstance(obj.tags, DummyQuerySet):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
return super().get_serializer_class()
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
|
||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
@@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filters.IPAddressFilterSet
|
||||
|
||||
@@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet):
|
||||
STATUS_RESERVED = 'reserved'
|
||||
STATUS_DEPRECATED = 'deprecated'
|
||||
STATUS_DHCP = 'dhcp'
|
||||
STATUS_SLAAC = 'slaac'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_ACTIVE, 'Active'),
|
||||
(STATUS_RESERVED, 'Reserved'),
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
(STATUS_DHCP, 'DHCP'),
|
||||
(STATUS_SLAAC, 'SLAAC'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -616,10 +616,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
elif type(instance.assigned_object) is VMInterface:
|
||||
initial['virtual_machine'] = instance.assigned_object.virtual_machine
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
if instance.nat_inside and instance.nat_inside.device is not None:
|
||||
initial['nat_site'] = instance.nat_inside.device.site
|
||||
initial['nat_rack'] = instance.nat_inside.device.rack
|
||||
initial['nat_device'] = instance.nat_inside.device
|
||||
if instance.nat_inside:
|
||||
nat_inside_parent = instance.nat_inside.assigned_object
|
||||
if type(nat_inside_parent) is Interface:
|
||||
initial['nat_site'] = nat_inside_parent.device.site.pk
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -107,7 +107,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.rd:
|
||||
return "{} ({})".format(self.name, self.rd)
|
||||
return f'{self.name} ({self.rd})'
|
||||
return self.name
|
||||
|
||||
|
||||
@@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
'reserved': 'info',
|
||||
'deprecated': 'danger',
|
||||
'dhcp': 'success',
|
||||
'slaac': 'success',
|
||||
}
|
||||
|
||||
ROLE_CLASS_MAP = {
|
||||
@@ -745,12 +746,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
|
||||
f"interface"
|
||||
})
|
||||
elif self.interface.virtual_machine != vm:
|
||||
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
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
'status': "Only IPv6 addresses can be assigned SLAAC status"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Force dns_name to lowercase
|
||||
@@ -985,9 +992,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.vid and self.name:
|
||||
return "{} ({})".format(self.vid, self.name)
|
||||
return None
|
||||
return f'{self.name} ({self.vid})'
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP[self.status]
|
||||
|
||||
@@ -387,15 +387,23 @@ class IPAddressTable(BaseTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
assigned = tables.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Interface'
|
||||
)
|
||||
assigned_object_parent = tables.Column(
|
||||
accessor='assigned_object__parent',
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Interface Parent'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
|
||||
'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
|
||||
@@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
|
||||
)
|
||||
filterset = filters.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.9-beta2'
|
||||
VERSION = '2.9.2'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -142,6 +142,13 @@ if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict:
|
||||
)
|
||||
except TypeError:
|
||||
raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.")
|
||||
# Backward compatibility for REMOTE_AUTH_BACKEND
|
||||
if REMOTE_AUTH_BACKEND == 'utilities.auth_backends.RemoteUserBackend':
|
||||
warnings.warn(
|
||||
"RemoteUserBackend has moved! Please update your configuration to:\n"
|
||||
" REMOTE_AUTH_BACKEND='netbox.authentication.RemoteUserBackend'"
|
||||
)
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -37,6 +37,7 @@ from secrets.tables import SecretTable
|
||||
from tenancy.filters import TenantFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.tables import TenantTable
|
||||
from utilities.utils import get_subquery
|
||||
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
|
||||
@@ -120,7 +121,10 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group'),
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
The complete exception is provided below:
|
||||
</p>
|
||||
<pre><strong>{{ exception }}</strong><br />
|
||||
{{ error }}</pre>
|
||||
{{ error }}
|
||||
|
||||
Python version: {{ python_version }}
|
||||
NetBox version: {{ netbox_version }}</pre>
|
||||
<p>
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
|
||||
</p>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
LAG interface<br />
|
||||
<small class="text-muted">
|
||||
{% for member in iface.member_interfaces.all %}
|
||||
<a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
|
||||
<a href="{{ member.get_absolute_url }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
No members
|
||||
{% endfor %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{% endif %}
|
||||
<span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
|
||||
</p>
|
||||
{% if result.completed and result.status != 'errored' %}
|
||||
{% if result.completed %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Report Methods</strong>
|
||||
@@ -75,10 +75,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif result.status == 'errored' %}
|
||||
<div class="well">Error during report execution</div>
|
||||
{% else %}
|
||||
<div class="well">Pending results</div>
|
||||
<div class="well">Pending results</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
|
||||
</p>
|
||||
<div role="tabpanel" class="tab-pane active" id="log">
|
||||
{% if result.completed and result.status != 'errored' %}
|
||||
{% if result.completed %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
@@ -76,12 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif result.stats == 'errored' %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="well">Error during script execution</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
|
||||
</li>
|
||||
{% if 'interface' in request.GET %}
|
||||
{% if 'interface' in request.GET or 'vminterface' in request.GET %}
|
||||
<li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
|
||||
</li>
|
||||
|
||||
@@ -66,6 +66,24 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.choice_values %}
|
||||
<button type="button" class="btn btn-primary btn-xs pull-right" data-toggle="modal" data-target="#{{ name }}_choices">
|
||||
<i class="fa fa-question"></i>
|
||||
</button>
|
||||
<div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title"><code>{{ name }}</code> Choices</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul>{% for value, label in field.choices %}{% if value %}<li>{{ value }}</li>{% endif %}{% endfor %}</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
{{ field.help_text }}<br />
|
||||
{% elif field.label %}
|
||||
|
||||
@@ -16,14 +16,6 @@
|
||||
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<ul>
|
||||
{% for error in field.errors %}
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif field|widget_type == 'textarea' and not field.label %}
|
||||
<div class="col-md-12">
|
||||
{{ field }}
|
||||
@@ -35,14 +27,6 @@
|
||||
{% if field.help_text %}
|
||||
<span class="help-block">{{ field.help_text|safe }}</span>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<ul>
|
||||
{% for error in field.errors %}
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<label class="col-md-3 control-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
<div class="col-md-9">
|
||||
@@ -55,13 +39,15 @@
|
||||
{% if field.help_text %}
|
||||
<span class="help-block">{{ field.help_text|safe }}</span>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<ul>
|
||||
{% for error in field.errors %}
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<ul>
|
||||
{% for error in field.errors %}
|
||||
{# Embed an HTML comment indicating the error for extraction by tests #}
|
||||
<!-- FORM-ERROR {{ field.name }}: {{ error }} -->
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
|
||||
|
||||
{# Checkbox #}
|
||||
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
|
||||
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
|
||||
</td>
|
||||
@@ -48,12 +48,12 @@
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% 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">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% 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">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
@@ -65,7 +65,7 @@
|
||||
{% if ipaddresses %}
|
||||
<tr class="ipaddresses">
|
||||
{# Placeholder #}
|
||||
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
|
||||
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -3,18 +3,19 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
def handle_protectederror(obj, request, e):
|
||||
def handle_protectederror(obj_list, request, e):
|
||||
"""
|
||||
Generate a user-friendly error message in response to a ProtectedError exception.
|
||||
"""
|
||||
protected_objects = list(e.protected_objects)
|
||||
err_message = f"Unable to delete {obj._meta.verbose_name} <strong>{obj}</strong>. " \
|
||||
f"{len(protected_objects)} dependent objects were found: "
|
||||
protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50'
|
||||
err_message = f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. " \
|
||||
f"{protected_count} dependent objects were found: "
|
||||
|
||||
# Append dependent objects to error message
|
||||
dependent_objects = []
|
||||
for dependent in protected_objects:
|
||||
if hasattr(obj, 'get_absolute_url'):
|
||||
for dependent in protected_objects[:50]:
|
||||
if hasattr(dependent, 'get_absolute_url'):
|
||||
dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
|
||||
else:
|
||||
dependent_objects.append(str(dependent))
|
||||
|
||||
@@ -142,9 +142,9 @@ class APISelect(SelectWithDisabled):
|
||||
|
||||
values = json.loads(self.attrs.get(key, '[]'))
|
||||
if type(value) is list:
|
||||
values.extend(value)
|
||||
values.extend([str(v) for v in value])
|
||||
else:
|
||||
values.append(value)
|
||||
values.append(str(value))
|
||||
|
||||
self.attrs[key] = json.dumps(values)
|
||||
|
||||
|
||||
@@ -3,20 +3,6 @@ from django.db.models import Q, QuerySet
|
||||
from utilities.permissions import permission_is_exempt
|
||||
|
||||
|
||||
class DummyQuerySet:
|
||||
"""
|
||||
A fake QuerySet that can be used to cache relationships to objects that have been deleted.
|
||||
"""
|
||||
def __init__(self, queryset):
|
||||
self._cache = [obj for obj in queryset.all()]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cache)
|
||||
|
||||
def all(self):
|
||||
return self._cache
|
||||
|
||||
|
||||
class RestrictedQuerySet(QuerySet):
|
||||
|
||||
def restrict(self, user, action='view'):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.contrib.auth.models import Permission, User
|
||||
@@ -43,6 +44,14 @@ def create_test_user(username='testuser', permissions=None):
|
||||
return user
|
||||
|
||||
|
||||
def extract_form_failures(content):
|
||||
"""
|
||||
Given raw HTML content from an HTTP response, return a list of form errors.
|
||||
"""
|
||||
FORM_ERROR_REGEX = r'<!-- FORM-ERROR (.*) -->'
|
||||
return re.findall(FORM_ERROR_REGEX, str(content))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disable_warnings(logger_name):
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ from taggit.managers import TaggableManager
|
||||
from extras.models import Tag
|
||||
from users.models import ObjectPermission
|
||||
from utilities.permissions import resolve_permission_ct
|
||||
from .utils import disable_warnings, post_data
|
||||
from .utils import disable_warnings, extract_form_failures, post_data
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -113,10 +113,18 @@ class TestCase(_TestCase):
|
||||
"""
|
||||
TestCase method. Provide more detail in the event of an unexpected HTTP response.
|
||||
"""
|
||||
err_message = "Expected HTTP status {}; received {}: {}"
|
||||
self.assertEqual(response.status_code, expected_status, err_message.format(
|
||||
expected_status, response.status_code, getattr(response, 'data', 'No data')
|
||||
))
|
||||
err_message = None
|
||||
# Construct an error message only if we know the test is going to fail
|
||||
if response.status_code != expected_status:
|
||||
if hasattr(response, 'data'):
|
||||
# REST API response; pass the response data through directly
|
||||
err = response.data
|
||||
else:
|
||||
# Attempt to extract form validation errors from the response HTML
|
||||
form_errors = extract_form_failures(response.content)
|
||||
err = form_errors or response.content or 'No data'
|
||||
err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
|
||||
self.assertEqual(response.status_code, expected_status, err_message)
|
||||
|
||||
def assertInstanceEqual(self, instance, data, api=False):
|
||||
"""
|
||||
|
||||
@@ -97,9 +97,10 @@ def serialize_object(obj, extra=None, exclude=None):
|
||||
field: str(value) for field, value in obj.cf.items()
|
||||
}
|
||||
|
||||
# Include any tags
|
||||
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
|
||||
if is_taggable(obj):
|
||||
data['tags'] = [tag.name for tag in obj.tags.all()]
|
||||
tags = getattr(obj, '_tags', obj.tags.all())
|
||||
data['tags'] = [tag.name for tag in tags]
|
||||
|
||||
# Append any extra data
|
||||
if extra is not None:
|
||||
@@ -276,6 +277,13 @@ def flatten_dict(d, prefix='', separator='.'):
|
||||
return ret
|
||||
|
||||
|
||||
# Taken from django.utils.functional (<3.0)
|
||||
def curry(_curried_func, *args, **kwargs):
|
||||
def _curried(*moreargs, **morekwargs):
|
||||
return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
|
||||
return _curried
|
||||
|
||||
|
||||
#
|
||||
# Fake request object
|
||||
#
|
||||
@@ -305,5 +313,6 @@ def copy_safe_request(request):
|
||||
'GET': request.GET,
|
||||
'FILES': request.FILES,
|
||||
'user': request.user,
|
||||
'path': request.path
|
||||
'path': request.path,
|
||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -509,7 +511,7 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
obj.delete()
|
||||
except ProtectedError as e:
|
||||
logger.info("Caught ProtectedError while attempting to delete object")
|
||||
handle_protectederror(obj, request, e)
|
||||
handle_protectederror([obj], request, e)
|
||||
return redirect(obj.get_absolute_url())
|
||||
|
||||
msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
|
||||
@@ -949,6 +951,12 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
|
||||
# Cache custom fields on instance prior to save()
|
||||
if custom_fields:
|
||||
obj._cf = {
|
||||
name: form.cleaned_data[name] for name in custom_fields
|
||||
}
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
updated_objects.append(obj)
|
||||
@@ -1158,7 +1166,7 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
deleted_count = queryset.delete()[1][model._meta.label]
|
||||
except ProtectedError as e:
|
||||
logger.info("Caught ProtectedError while attempting to delete objects")
|
||||
handle_protectederror(list(queryset), request, e)
|
||||
handle_protectederror(queryset, request, e)
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
|
||||
@@ -1415,6 +1423,8 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error, traceback = sys.exc_info()
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'python_version': platform.python_version(),
|
||||
'netbox_version': settings.VERSION,
|
||||
'exception': str(type_),
|
||||
'error': error,
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
@@ -325,28 +326,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
# Gather PKs of all interfaces belonging to this VM
|
||||
interface_ids = self.instance.interfaces.values_list('pk', flat=True)
|
||||
|
||||
# Collect interface IPs
|
||||
interface_ips = IPAddress.objects.prefetch_related('interface').filter(
|
||||
interface_ips = IPAddress.objects.filter(
|
||||
address__family=family,
|
||||
vminterface__in=self.instance.interfaces.values_list('id', flat=True)
|
||||
assigned_object_type=ContentType.objects.get_for_model(VMInterface),
|
||||
assigned_object_id__in=interface_ids
|
||||
)
|
||||
if interface_ips:
|
||||
ip_choices.append(
|
||||
('Interface IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
|
||||
ip_choices.append(('Interface IPs', ip_list))
|
||||
# Collect NAT IPs
|
||||
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
|
||||
address__family=family,
|
||||
nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
|
||||
nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
|
||||
nat_inside__assigned_object_id__in=interface_ids
|
||||
)
|
||||
if nat_ips:
|
||||
ip_choices.append(
|
||||
('NAT IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
else:
|
||||
@@ -683,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm):
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
|
||||
@@ -335,13 +335,13 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
for field in ['primary_ip4', 'primary_ip6']:
|
||||
ip = getattr(self, field)
|
||||
if ip is not None:
|
||||
if ip.interface in interfaces:
|
||||
if ip.assigned_object in interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
|
||||
elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
|
||||
field: f"The specified IP address ({ip}) is not assigned to this VM.",
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
|
||||
@@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='virtualization:vminterface_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
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',
|
||||
)
|
||||
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
|
||||
|
||||
Reference in New Issue
Block a user