Compare commits

..

85 Commits

Author SHA1 Message Date
Jeremy Stretch
f26227ecec Merge pull request #6180 from netbox-community/develop
Release v2.11.0
2021-04-16 09:52:45 -04:00
Jeremy Stretch
c002501c2e Merge branch 'master' into develop 2021-04-16 09:40:29 -04:00
jeremystretch
8af78c6d84 Release v2.11.0 2021-04-16 09:36:05 -04:00
jeremystretch
7e8ffd8390 Fix markdown list indentation 2021-04-16 09:32:37 -04:00
jeremystretch
5bd30060e7 Fixes #6177: Prevent VM interface from being assigned as its own parent 2021-04-16 09:18:58 -04:00
jeremystretch
c24cac9a44 Fixes #6176: Correct position of MAC address field when creating VM interfaces 2021-04-16 09:16:43 -04:00
jeremystretch
2bc9730b1d Merge release notes from v2.11-beta1 2021-04-16 09:16:01 -04:00
Jeremy Stretch
428858dc75 Merge pull request #6175 from netbox-community/feature
Prep for v2.11 release
2021-04-16 08:45:23 -04:00
jeremystretch
8a425a93ae Fixes #6173: Fix assigned device/VM count when bulk editing/deleting device roles 2021-04-15 19:53:46 -04:00
jeremystretch
0bb2a051b6 Fixes #6171: Fix display of horizontally-scrolling object lists 2021-04-15 15:52:11 -04:00
jeremystretch
ef78a7d41b Merge branch 'develop' into feature 2021-04-15 15:46:48 -04:00
Jeremy Stretch
6c1c695616 Merge pull request #6172 from netbox-community/develop
Release v2.10.10
2021-04-15 15:34:24 -04:00
Jeremy Stretch
8bdab347b6 Merge branch 'master' into develop 2021-04-15 15:24:36 -04:00
jeremystretch
ae349d4424 Release v2.10.10 2021-04-15 15:20:30 -04:00
jeremystretch
3e49745257 Changelog for #6168 2021-04-15 13:30:42 -04:00
Jeremy Stretch
c2b7226205 Merge pull request #6169 from promasu/develop
Fixes #6168: Add SFP56 interface type
2021-04-15 13:29:44 -04:00
jeremystretch
f67369a9a9 Fixes #5643: Fix VLAN assignment when editing VM interfaces in bulk 2021-04-15 13:26:19 -04:00
Adrian Nöthlich
14d2a49942 Fixes #6168: Add SFP56 interface type
Signed-off-by: Adrian Nöthlich <git@promasu.tech>
2021-04-15 18:37:45 +02:00
jeremystretch
92df40a6a0 Fixes #5652: Update object data when renaming a custom field 2021-04-15 12:04:34 -04:00
jeremystretch
a2203da1c6 Fixes #6162: Fix choice field filters (multiple models) 2021-04-14 16:17:19 -04:00
jeremystretch
0fb8291b8b Documentation cleanup 2021-04-14 15:21:30 -04:00
jeremystretch
b393ec2856 Enable webhooks for JournalEntry 2021-04-14 14:55:01 -04:00
jeremystretch
664ba55460 Reorganize base TestCase classes 2021-04-14 14:22:58 -04:00
jeremystretch
3d0882856f Closes #5796: Add DC terminal power port, outlet types 2021-04-14 10:44:15 -04:00
jeremystretch
e849d28276 Closes #5980: Add Saf-D-Grid power port, outlet types 2021-04-14 10:40:31 -04:00
jeremystretch
18a691482d Closes #6160: Add F connector port type 2021-04-14 10:38:54 -04:00
jeremystretch
7c48e3632d Closes #6157: Support Markdown rendering for report logs 2021-04-14 10:21:07 -04:00
jeremystretch
8701b20899 Fixes #6155: Fix admin links for plugins, background tasks 2021-04-14 09:46:05 -04:00
jeremystretch
e3f5062583 Merge branch 'develop' into feature 2021-04-13 16:41:46 -04:00
jeremystretch
d427e0522e Closes #5757: Add unique identifier to every object view 2021-04-13 16:14:12 -04:00
jeremystretch
46e144f647 Clean up object header 2021-04-13 16:03:07 -04:00
jeremystretch
6ad20c53d9 Delete unused template 2021-04-13 15:58:21 -04:00
jeremystretch
cc433388f5 Fixes #6056: Optimize change log cleanup 2021-04-13 13:48:22 -04:00
jeremystretch
1fba4b7e32 Fixes #5419: Update parent device/VM when deleting a primary IP 2021-04-13 13:23:25 -04:00
jeremystretch
c249cd4ffd Fixes #6152: Fix custom field filtering for cables, virtual chassis 2021-04-13 12:05:44 -04:00
jeremystretch
d54bf5f75e Fixes #6144: Fix MAC address field display in VM interfaces search form 2021-04-13 11:52:32 -04:00
jeremystretch
9cbe3ff551 Enable close-stale-issue action 2021-04-13 11:46:14 -04:00
jeremystretch
b1d20d3228 Closes #6149: Support image attachments for locations 2021-04-13 11:39:04 -04:00
jeremystretch
e5602abee0 Closes #5848: Filter custom fields by content type in format <app_label>.<model> 2021-04-13 11:30:45 -04:00
jeremystretch
a296a9e109 Closes #6150: Enable change logging for journal entries 2021-04-13 10:53:55 -04:00
jeremystretch
e5bbf47ab9 Fixes #5583: Eliminate redundant change records when adding/removing tags 2021-04-13 10:14:25 -04:00
jeremystretch
9bda2a44ae Fix permissions for cable trace view tests 2021-04-12 16:25:52 -04:00
jeremystretch
a1d32c3a21 Add view tests for CircuitTermination 2021-04-12 16:07:52 -04:00
jeremystretch
b4b68c0b00 Move create_test_device() to testing utils 2021-04-12 16:07:03 -04:00
jeremystretch
608bf30bda Add cable trace view tests 2021-04-12 15:52:24 -04:00
jeremystretch
d0be5f09a5 Merge branch 'develop' into feature 2021-04-12 15:35:12 -04:00
jeremystretch
3d8a3a2204 Fix link 2021-04-12 15:17:50 -04:00
jeremystretch
b19734004a Removed the "Additional information" blocks from issue templates (no longer needed) 2021-04-12 15:06:40 -04:00
jeremystretch
f503c72782 Closes #6146: Add bulk disconnect support for power feeds 2021-04-12 15:02:29 -04:00
jeremystretch
f8903a8183 Merge branch 'develop' into feature 2021-04-12 14:19:30 -04:00
jeremystretch
d43d5a6cb6 Change stale issues management to GitHub Actions 2021-04-12 13:53:38 -04:00
jeremystretch
99f4b2cf95 PRVB 2021-04-12 13:29:37 -04:00
Jeremy Stretch
b493d739bd Merge pull request #6143 from netbox-community/develop
Release v2.10.9
2021-04-12 13:28:28 -04:00
jeremystretch
92fb43a455 Merge branch 'master' into develop 2021-04-12 13:18:24 -04:00
jeremystretch
7c29fb449e Release v2.10.9 2021-04-12 13:10:33 -04:00
jeremystretch
7ddcec3a0d Fixes #6082: Support colons in webhook header values 2021-04-12 12:09:37 -04:00
jeremystretch
18f206747c Closes #6088: Improved table configuration form 2021-04-12 10:46:32 -04:00
jeremystretch
0bce1da4e3 Clean up stray references to old RackGroup model 2021-04-11 13:43:06 -04:00
jeremystretch
65ed047084 Fixes #6124: Location parent filter should return all child locations (not just those directly assigned) 2021-04-11 13:42:24 -04:00
jeremystretch
b0573f88e6 Merge branch 'develop' into feature 2021-04-11 13:16:00 -04:00
jeremystretch
348fca7e28 Fixes #6117: Handle exception when attempting to assign an MPTT-enabled model as its own parent 2021-04-11 12:57:53 -04:00
jeremystretch
cc9b750eff Changelog & docs for #6083 2021-04-09 14:58:40 -04:00
Marcus Weiner
701ad8a4a9 Allow skipping TLS cert verification on Redis connection (#6084)
* Allow skipping redis tls cert verification

* Add config example
2021-04-09 14:51:58 -04:00
jeremystretch
2cc088c633 Fixes #6131: Correct handling of boolean fields when cloning objects 2021-04-09 14:42:07 -04:00
tcaiazza
4dfba3a2ad Update export-templates.md (#6091)
* Update export-templates.md

* Update export-templates.md

Co-authored-by: Jeremy Stretch <jeremy@netverity.dev>
2021-04-09 14:14:08 -04:00
jeremystretch
a3721a94ce Closes #6121: Extend parent interface assignment to VM interfaces 2021-04-09 10:53:05 -04:00
jeremystretch
7439faad34 Fixes #6123: Prevent device from being assigned to mismatched site and location 2021-04-09 09:56:36 -04:00
jeremystretch
6efe54aa88 Closes #6125: Add locations count to home page 2021-04-09 09:47:34 -04:00
jeremystretch
9e62d1ad8f Fixes #6130: Improve display of assigned models in custom fields list 2021-04-09 09:43:35 -04:00
jeremystretch
e69251b21a Fixes #6070: Add missing 'count_ipaddresses' attribute to VMInterface serializer 2021-04-08 14:22:45 -04:00
jeremystretch
f096c4a5d0 #6081: Tweak queryset filtering 2021-04-08 14:18:07 -04:00
jeremystretch
03b3f5937f Fixes #6108: Do not infer tenant assignment from parent objects for prefixes, IP addresses 2021-04-08 13:50:06 -04:00
jeremystretch
696b5c80a7 Closes #6097: Redirect old slug-based object views 2021-04-08 13:25:29 -04:00
jeremystretch
d6fcd22752 Fixes #6110: Fix handling of TemplateColumn values for table export 2021-04-08 10:30:13 -04:00
jeremystretch
54d9ca8ed8 Add racks count to location view 2021-04-08 10:10:46 -04:00
jeremystretch
05d8a06cd5 Closes #6109: Add device counts to locations table 2021-04-08 10:08:50 -04:00
jeremystretch
4f7626828a Fixes #6107: Fix rack selection field on device form 2021-04-07 16:58:40 -04:00
jeremystretch
81193eb550 Fixes #6106: Allow assigning a virtual interface as the parent of an existing interface 2021-04-07 16:36:09 -04:00
jeremystretch
38b09dc610 Fixes #6105: Hide checkboxes for VMs under cluster VMs view 2021-04-07 16:26:16 -04:00
jeremystretch
ae3527df16 Fixes #6081: Fix interface connections REST API endpoint 2021-04-07 16:04:32 -04:00
jeremystretch
85d0270af0 Fixes #6099: Correct example permission description 2021-04-07 15:50:24 -04:00
jeremystretch
59e185b781 Fixes #6104: Fix location column on racks table 2021-04-07 15:40:03 -04:00
jeremystretch
b5ad29e3f2 Fixes #6100: Fix VM interfaces table "add interfaces" link 2021-04-07 15:17:02 -04:00
jeremystretch
d42b0691b2 Fix 'select all' widget 2021-04-05 17:13:32 -04:00
Jeremy Stretch
3d286fbdc3 Merge pull request #6061 from netbox-community/develop
Release v2.10.8
2021-03-26 10:22:42 -04:00
96 changed files with 1398 additions and 621 deletions

View File

@@ -56,8 +56,3 @@ body:
placeholder: "A TypeError exception was raised"
validations:
required: true
- type: markdown
attributes:
value: |
### Additional information
You can use the space below to provide any additional information or to attach files.

View File

@@ -33,8 +33,3 @@ body:
description: "Describe the proposed changes and why they are necessary"
validations:
required: true
- type: markdown
attributes:
value: |
### Additional information
You can use the space below to provide any additional information or to attach files.

View File

@@ -51,8 +51,3 @@ body:
description: "List any new dependencies on external libraries or services that this
new feature would introduce. For example, does the proposal require the installation
of a new Python package? (Not all new features introduce new dependencies.)"
- type: markdown
attributes:
value: |
### Additional information
You can use the space below to provide any additional information or to attach files.

View File

@@ -20,8 +20,3 @@ body:
description: "Please provide justification for the proposed change(s)."
validations:
required: true
- type: markdown
attributes:
value: |
### Additional information
You can use the space below to provide any additional information or to attach files.

30
.github/stale.yml vendored
View File

@@ -1,30 +0,0 @@
# Configuration for Stale (https://github.com/apps/stale)
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 45
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 15
# Issues with these labels will never be considered stale
exemptLabels:
- "status: accepted"
- "status: blocked"
- "status: needs milestone"
# Label to use when marking an issue as stale
staleLabel: "pending closure"
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. NetBox
is governed by a small group of core maintainers which means not all opened
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
This issue has been automatically closed due to lack of activity. In an
effort to reduce noise, please do not comment any further. Note that the
core maintainers may elect to reopen this issue at a later date if deemed
necessary.

34
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs'
on:
schedule:
- cron: '0 4 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v3
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an
effort to reduce noise, please do not comment any further. Note that the
core maintainers may elect to reopen this issue at a later date if deemed
necessary.
close-pr-message: >
This PR has been automatically closed due to lack of activity.
days-before-stale: 45
days-before-close: 15
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
remove-stale-when-updated: false
stale-issue-label: 'pending closure'
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. NetBox
is governed by a small group of core maintainers which means not all opened
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
stale-pr-label: 'pending closure'
stale-pr-message: >
This PR has been automatically marked as stale because it has not had
recent activity. It will be closed automatically if no further action is
taken.

View File

@@ -21,6 +21,14 @@ Height: {{ rack.u_height }}U
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
```
{% for server in queryset %}
{% set data = server.get_config_context() %}
{{ data.syslog }}
{% endfor %}
```
The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.)
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.

View File

@@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
| ----------- | ----------- |
| `{"status": "active"}` | Status is active |
| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |

View File

@@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended)
An example configuration is provided below:

View File

@@ -4,13 +4,15 @@
A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type.
The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`).
### Features Matrix
* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
* Tagging - The models can be tagged with user-defined tags
* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
* Nesting - These models can be nested recursively to create a hierarchy

View File

@@ -1,4 +1,4 @@
# Provider Network
# Provider Networks
This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity.

View File

@@ -1,6 +1,28 @@
# NetBox v2.10
## v2.10.9 (FUTURE)
## v2.10.10 (2021-04-15)
### Enhancements
* [#5796](https://github.com/netbox-community/netbox/issues/5796) - Add DC terminal power port, outlet types
* [#5980](https://github.com/netbox-community/netbox/issues/5980) - Add Saf-D-Grid power port, outlet types
* [#6157](https://github.com/netbox-community/netbox/issues/6157) - Support Markdown rendering for report logs
* [#6160](https://github.com/netbox-community/netbox/issues/6160) - Add F connector port type
* [#6168](https://github.com/netbox-community/netbox/issues/6168) - Add SFP56 50GE interface type
### Bug Fixes
* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
* [#5643](https://github.com/netbox-community/netbox/issues/5643) - Fix VLAN assignment when editing VM interfaces in bulk
* [#5652](https://github.com/netbox-community/netbox/issues/5652) - Update object data when renaming a custom field
* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models)
---
## v2.10.9 (2021-04-12)
### Enhancements
@@ -8,11 +30,18 @@
* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view
* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color
* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant
* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis
### Bug Fixes
* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations
* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer
* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission
* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint
* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values
* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses
* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent
* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects
---
@@ -271,43 +300,43 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
* Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint)
* Added the `/status/` endpoint to convey NetBox's current status
* circuits.CircuitTermination:
* Added the `/trace/` endpoint
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* `port_speed` may now be null
* Added the `/trace/` endpoint
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* `port_speed` may now be null
* dcim.Cable: Added `custom_fields`
* dcim.ConsolePort:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.ConsoleServerPort:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.FrontPort:
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* dcim.Interface:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning
* dcim.PowerFeed:
* Added the `/trace/` endpoint
* Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type`
* Added the `/trace/` endpoint
* Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type`
* dcim.PowerOutlet:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.PowerPanel: Added `custom_fields`
* dcim.PowerPort
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.RackReservation: Added `custom_fields`
* dcim.RearPort:
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* dcim.VirtualChassis: Added `custom_fields`
* extras.ExportTemplate: The `template_language` field has been removed
* extras.Graph: This API endpoint has been removed (see #4349)

View File

@@ -1,8 +1,6 @@
# NetBox v2.11
## v2.11-beta1 (2021-04-07)
**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.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost.
## v2.11.0 (2021-04-16)
**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required.
@@ -23,7 +21,7 @@ NetBox now supports journaling for all primary objects. The journal is a collect
#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
Virtual interfaces can now be assigned to a "parent" physical interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
@@ -93,7 +91,9 @@ A new provider network model has been introduced to represent the boundary of a
* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields
* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links
* [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks
* [#5757](https://github.com/netbox-community/netbox/issues/5757) - Add unique identifier to every object view
* [#5830](https://github.com/netbox-community/netbox/issues/5830) - Add `as_attachment` to ExportTemplate to control download behavior
* [#5848](https://github.com/netbox-community/netbox/issues/5848) - Filter custom fields by content type in format `<app_label>.<model>`
* [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers
* [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
* [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location
@@ -103,6 +103,29 @@ A new provider network model has been introduced to represent the boundary of a
* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines
* [#6001](https://github.com/netbox-community/netbox/issues/6001) - Paginate component tables under device views
* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view
* [#6088](https://github.com/netbox-community/netbox/issues/6088) - Improved table configuration form
* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views
* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page
* [#6146](https://github.com/netbox-community/netbox/issues/6146) - Add bulk disconnect support for power feeds
* [#6149](https://github.com/netbox-community/netbox/issues/6149) - Support image attachments for locations
### Bug Fixes (from v2.11-beta1)
* [#5583](https://github.com/netbox-community/netbox/issues/5583) - Eliminate redundant change records when adding/removing tags
* [#6100](https://github.com/netbox-community/netbox/issues/6100) - Fix VM interfaces table "add interfaces" link
* [#6104](https://github.com/netbox-community/netbox/issues/6104) - Fix location column on racks table
* [#6105](https://github.com/netbox-community/netbox/issues/6105) - Hide checkboxes for VMs under cluster VMs view
* [#6106](https://github.com/netbox-community/netbox/issues/6106) - Allow assigning a virtual interface as the parent of an existing interface
* [#6107](https://github.com/netbox-community/netbox/issues/6107) - Fix rack selection field on device form
* [#6110](https://github.com/netbox-community/netbox/issues/6110) - Fix handling of TemplateColumn values for table export
* [#6123](https://github.com/netbox-community/netbox/issues/6123) - Prevent device from being assigned to mismatched site and location
* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned)
* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list
* [#6155](https://github.com/netbox-community/netbox/issues/6155) - Fix admin links for plugins, background tasks
* [#6171](https://github.com/netbox-community/netbox/issues/6171) - Fix display of horizontally-scrolling object lists
* [#6173](https://github.com/netbox-community/netbox/issues/6173) - Fix assigned device/VM count when bulk editing/deleting device roles
* [#6176](https://github.com/netbox-community/netbox/issues/6176) - Correct position of MAC address field when creating VM interfaces
* [#6177](https://github.com/netbox-community/netbox/issues/6177) - Prevent VM interface from being assigned as its own parent
### Other Changes
@@ -119,49 +142,51 @@ A new provider network model has been introduced to represent the boundary of a
* All primary keys are now 64-bit integers
* All model serializers now include a `display` field to be used for the presentation of an object to a human user
* All device components
* Added support for custom fields
* Added `created` and `last_updated` fields to track object creation and modification
* Added support for custom fields
* Added `created` and `last_updated` fields to track object creation and modification
* All device component templates
* Added `created` and `last_updated` fields to track object creation and modification
* Added `created` and `last_updated` fields to track object creation and modification
* All organizational models
* Added support for custom fields
* Added support for custom fields
* All cable termination models (cabled device components, power feeds, and circuit terminations)
* Added `mark_connected` boolean field to force connection status
* Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
* Added `mark_connected` boolean field to force connection status
* Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
* Renamed RackGroup to Location
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
* circuits.CircuitTermination
* Added the `provider_network` field
* Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
* Added the `provider_network` field
* Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
* circuits.ProviderNetwork
* Added the `/api/circuits/provider-networks/` endpoint
* Added the `/api/circuits/provider-networks/` endpoint
* dcim.Device
* Added the `location` field
* Added the `location` field
* dcim.Interface
* Added the `parent` field
* Added the `parent` field
* dcim.PowerPanel
* Renamed `rack_group` field to `location`
* Renamed `rack_group` field to `location`
* dcim.Rack
* Renamed `group` field to `location`
* Renamed `group` field to `location`
* dcim.Site
* Added the `group` foreign key field to SiteGroup
* Added the `group` foreign key field to SiteGroup
* dcim.SiteGroup
* Added the `/api/dcim/site-groups/` endpoint
* Added the `/api/dcim/site-groups/` endpoint
* extras.ConfigContext
* Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
* Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
* extras.CustomField
* Added new custom field type: `multi-select`
* Added new custom field type: `multi-select`
* extras.CustomLink
* Added the `/api/extras/custom-links/` endpoint
* Added the `/api/extras/custom-links/` endpoint
* extras.ExportTemplate
* Added the `as_attachment` boolean field
* Added the `as_attachment` boolean field
* extras.ObjectChange
* Added the `prechange_data` field
* Renamed `object_data` to `postchange_data`
* Added the `prechange_data` field
* Renamed `object_data` to `postchange_data`
* extras.Webhook
* Added the `/api/extras/webhooks/` endpoint
* Added the `/api/extras/webhooks/` endpoint
* ipam.VLANGroup
* Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key)
* Dropped the `site` foreign key field
* Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key)
* Dropped the `site` foreign key field
* virtualization.VirtualMachine
* `vcpus` has been changed from an integer to a decimal value
* `vcpus` has been changed from an integer to a decimal value
* virtualization.VMInterface
* Added the `parent` field

View File

@@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
### Creating a New Object
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/index.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
```no-highlight
curl -s -X POST \

View File

@@ -1,8 +1,12 @@
import datetime
from django.test import override_settings
from django.urls import reverse
from circuits.choices import *
from circuits.models import *
from utilities.testing import ViewTestCases
from dcim.models import Cable, Interface, Site
from utilities.testing import ViewTestCases, create_tags, create_test_device
class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -17,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Provider X',
@@ -102,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cid': 'Circuit X',
@@ -153,7 +157,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Provider Network X',
@@ -175,3 +179,57 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
'comments': 'New comments',
}
class CircuitTerminationTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
model = CircuitTermination
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]),
CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
cls.form_data = {
'term_side': 'A',
'site': sites[2].pk,
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
device = create_test_device('Device 1')
circuittermination = CircuitTermination.objects.first()
interface = Interface.objects.create(
device=device,
name='Interface 1'
)
Cable(termination_a=circuittermination, termination_b=interface).save()
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)

View File

@@ -2,6 +2,7 @@ from django.urls import path
from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView, ObjectJournalView
from utilities.views import SlugRedirectView
from . import views
from .models import *
@@ -15,6 +16,7 @@ urlpatterns = [
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers/<int:pk>/', views.ProviderView.as_view(), name='provider'),
path('providers/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Provider}),
path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),

View File

@@ -842,7 +842,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField()
interface_b = NestedInterfaceSerializer(source='connected_endpoint')
interface_b = NestedInterfaceSerializer(source='_path.destination')
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta:

View File

@@ -2,6 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import F
from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404
@@ -141,7 +142,7 @@ class SiteViewSet(CustomFieldModelViewSet):
#
# Rack groups
# Locations
#
class LocationViewSet(CustomFieldModelViewSet):
@@ -596,6 +597,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related('device', '_path').filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
_path__destination_type__app_label='dcim',
_path__destination_type__model='interface',
_path__destination_id__isnull=False,
pk__lt=F('_path__destination_id')
)

View File

@@ -337,6 +337,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
TYPE_SAF_D_GRID = 'saf-d-grid'
CHOICES = (
('IEC 60320', (
@@ -437,6 +441,12 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
('Proprietary', (
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
)),
)
@@ -530,8 +540,11 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
TYPE_HDOT_CX = 'hdot-cx'
TYPE_SAF_D_GRID = 'saf-d-grid'
CHOICES = (
('IEC 60320', (
@@ -625,8 +638,12 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
('Proprietary', (
(TYPE_HDOT_CX, 'HDOT Cx'),
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
)),
)
@@ -668,6 +685,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
TYPE_10GE_X2 = '10gbase-x-x2'
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
TYPE_50GE_SFP56 = '50gbase-x-sfp56'
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
TYPE_100GE_CFP = '100gbase-x-cfp'
@@ -772,6 +790,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
(TYPE_10GE_X2, 'X2 (10GE)'),
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
(TYPE_100GE_CFP, 'CFP (100GE)'),
@@ -904,6 +923,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_TERA1P = 'tera-1p'
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_F = 'f'
TYPE_MRJ21 = 'mrj21'
TYPE_ST = 'st'
TYPE_SC = 'sc'
@@ -933,6 +953,7 @@ class PortTypeChoices(ChoiceSet):
(TYPE_TERA1P, 'TERA 1P'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_F, 'F Connector'),
(TYPE_MRJ21, 'MRJ21'),
),
),

View File

@@ -191,15 +191,18 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
to_field_name='slug',
label='Site (slug)',
)
parent_id = django_filters.ModelMultipleChoiceFilter(
parent_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
label='Rack group (ID)',
field_name='parent',
lookup_expr='in',
label='Location (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
parent = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='parent',
lookup_expr='in',
to_field_name='slug',
label='Rack group (slug)',
label='Location (slug)',
)
class Meta:
@@ -495,6 +498,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
feed_leg = django_filters.MultipleChoiceFilter(
choices=PowerOutletFeedLegChoices,
null_value=None
)
class Meta:
model = PowerOutletTemplate
@@ -502,6 +509,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices,
null_value=None
)
class Meta:
model = InterfaceTemplate
@@ -509,6 +520,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
)
class Meta:
model = FrontPortTemplate
@@ -516,6 +531,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
)
class Meta:
model = RearPortTemplate
@@ -892,6 +911,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
choices=PowerOutletTypeChoices,
null_value=None
)
feed_leg = django_filters.MultipleChoiceFilter(
choices=PowerOutletFeedLegChoices,
null_value=None
)
class Meta:
model = PowerOutlet
@@ -997,6 +1020,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
)
class Meta:
model = FrontPort
@@ -1004,6 +1031,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
)
class Meta:
model = RearPort
@@ -1090,7 +1121,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
return queryset.filter(qs_filter)
class VirtualChassisFilterSet(BaseFilterSet):
class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1170,7 +1201,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
return queryset.filter(qs_filter).distinct()
class CableFilterSet(BaseFilterSet):
class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1346,7 +1377,7 @@ class PowerPanelFilterSet(BaseFilterSet):
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
label='Rack group (ID)',
label='Location (ID)',
)
tag = TagFilter()
@@ -1420,6 +1451,10 @@ class PowerFeedFilterSet(
queryset=Rack.objects.all(),
label='Rack (ID)',
)
status = django_filters.MultipleChoiceFilter(
choices=PowerFeedStatusChoices,
null_value=None
)
tag = TagFilter()
class Meta:

View File

@@ -521,9 +521,9 @@ class LocationCSVForm(CustomFieldModelCSVForm):
queryset=Location.objects.all(),
required=False,
to_field_name='name',
help_text='Parent rack group',
help_text='Parent location',
error_messages={
'invalid_choice': 'Rack group not found.',
'invalid_choice': 'Location not found.',
}
)
@@ -2056,7 +2056,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False,
query_params={
'site_id': '$site',
'location_id': 'location',
'location_id': '$location',
}
)
position = forms.IntegerField(
@@ -3072,10 +3072,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='Parent interface',
query_params={
'kind': 'physical',
}
label='Parent interface'
)
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),

View File

@@ -507,6 +507,10 @@ class BaseInterface(models.Model):
return super().save(*args, **kwargs)
@property
def count_ipaddresses(self):
return self.ip_addresses.count()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
@@ -674,10 +678,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG
@property
def count_ipaddresses(self):
return self.ip_addresses.count()
#
# Pass-through ports

View File

@@ -652,6 +652,10 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
if self.location and self.site != self.location.site:
raise ValidationError({
'location': f"Location {self.location} does not belong to site {self.site}.",
})
if self.rack and self.location and self.rack.location != self.location:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",

View File

@@ -66,9 +66,9 @@ class PowerPanel(PrimaryModel):
# Location must belong to assigned Site
if self.location and self.location.site != self.site:
raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
self.location, self.location.site, self.site
))
raise ValidationError(
f"Location {self.location} ({self.location.site}) is in a different site than {self.site}"
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')

View File

@@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from django.core.exceptions import ValidationError
from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
@@ -313,6 +314,9 @@ class Location(NestedGroupModel):
max_length=200,
blank=True
)
images = GenericRelation(
to='extras.ImageAttachment'
)
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
clone_fields = ['site', 'parent', 'description']

View File

@@ -1,49 +1,21 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, Location, RackReservation, RackRole
from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
TagColumn, ToggleColumn, UtilizationColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
ToggleColumn, UtilizationColumn,
)
from .template_code import LOCATION_ELEVATIONS
__all__ = (
'RackTable',
'RackDetailTable',
'LocationTable',
'RackReservationTable',
'RackRoleTable',
)
#
# Locations
#
class LocationTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
linkify=True
)
site = tables.Column(
linkify=True
)
rack_count = tables.Column(
verbose_name='Racks'
)
actions = ButtonsColumn(
model=Location,
prepend_template=LOCATION_ELEVATIONS
)
class Meta(BaseTable.Meta):
model = Location
fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
#
# Rack roles
#
@@ -71,7 +43,7 @@ class RackTable(BaseTable):
order_by=('_name',),
linkify=True
)
group = tables.Column(
location = tables.Column(
linkify=True
)
site = tables.Column(
@@ -88,10 +60,10 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
@@ -113,11 +85,11 @@ class RackDetailTable(RackTable):
class Meta(RackTable.Meta):
fields = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization',
)

View File

@@ -1,12 +1,14 @@
import django_tables2 as tables
from dcim.models import Region, Site, SiteGroup
from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .template_code import LOCATION_ELEVATIONS
__all__ = (
'LocationTable',
'RegionTable',
'SiteTable',
'SiteGroupTable',
@@ -86,3 +88,32 @@ class SiteTable(BaseTable):
'contact_email', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')
#
# Locations
#
class LocationTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
linkify=True
)
site = tables.Column(
linkify=True
)
rack_count = tables.Column(
verbose_name='Racks'
)
device_count = tables.Column(
verbose_name='Devices'
)
actions = ButtonsColumn(
model=Location,
prepend_template=LOCATION_ELEVATIONS
)
class Meta(BaseTable.Meta):
model = Location
fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions')

View File

@@ -940,9 +940,8 @@ class PowerOutletTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_feed_leg(self):
# TODO: Support filtering for multiple values
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class InterfaceTemplateTestCase(TestCase):
@@ -981,9 +980,8 @@ class InterfaceTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Support filtering for multiple values
params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mgmt_only(self):
params = {'mgmt_only': 'true'}
@@ -1035,9 +1033,8 @@ class FrontPortTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Support filtering for multiple values
params = {'type': PortTypeChoices.TYPE_8P8C}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTemplateTestCase(TestCase):
@@ -1076,9 +1073,8 @@ class RearPortTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Support filtering for multiple values
params = {'type': PortTypeChoices.TYPE_8P8C}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_positions(self):
params = {'positions': [1, 2]}
@@ -1985,9 +1981,8 @@ class PowerOutletTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_feed_leg(self):
# TODO: Support filtering for multiple values
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
@@ -2284,9 +2279,8 @@ class FrontPortTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Test for multiple values
params = {'type': PortTypeChoices.TYPE_8P8C}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['First', 'Second']}
@@ -2396,9 +2390,8 @@ class RearPortTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Test for multiple values
params = {'type': PortTypeChoices.TYPE_8P8C}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_positions(self):
params = {'positions': [1, 2]}
@@ -3045,9 +3038,8 @@ class PowerFeedTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
# TODO: Test for multiple values
params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}

View File

@@ -12,20 +12,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import VLAN
from utilities.testing import ViewTestCases
def create_test_device(name):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
return device
from utilities.testing import ViewTestCases, create_tags, create_test_device
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -122,7 +109,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Site X',
@@ -255,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'rack': rack.pk,
@@ -311,7 +298,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Rack(name='Rack 3', site=sites[0]),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Rack X',
@@ -426,7 +413,7 @@ class DeviceTypeTestCase(
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
@@ -1034,7 +1021,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device_type': devicetypes[1].pk,
@@ -1214,7 +1201,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsolePort(device=device, name='Console Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1246,6 +1233,18 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Port 6",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleport = ConsolePort.objects.first()
consoleserverport = ConsoleServerPort.objects.create(
device=consoleport.device,
name='Console Server Port 1'
)
Cable(termination_a=consoleport, termination_b=consoleserverport).save()
response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
self.assertHttpStatus(response, 200)
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort
@@ -1260,7 +1259,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsoleServerPort(device=device, name='Console Server Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1290,6 +1289,18 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Server Port 6",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleserverport = ConsoleServerPort.objects.first()
consoleport = ConsolePort.objects.create(
device=consoleserverport.device,
name='Console Port 1'
)
Cable(termination_a=consoleserverport, termination_b=consoleport).save()
response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
self.assertHttpStatus(response, 200)
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort
@@ -1304,7 +1315,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerPort(device=device, name='Power Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1340,6 +1351,18 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Port 6",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
powerport = PowerPort.objects.first()
poweroutlet = PowerOutlet.objects.create(
device=powerport.device,
name='Power Outlet 1'
)
Cable(termination_a=powerport, termination_b=poweroutlet).save()
response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
self.assertHttpStatus(response, 200)
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet
@@ -1360,7 +1383,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1396,6 +1419,15 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Outlet 6",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
poweroutlet = PowerOutlet.objects.first()
powerport = PowerPort.objects.first()
Cable(termination_a=poweroutlet, termination_b=powerport).save()
response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
self.assertHttpStatus(response, 200)
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
@@ -1420,7 +1452,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1475,6 +1507,14 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Interface 6,1000base-t",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2]
Cable(termination_a=interface1, termination_b=interface2).save()
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
self.assertHttpStatus(response, 200)
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort
@@ -1499,7 +1539,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1534,6 +1574,18 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Front Port 6,8p8c,Rear Port 6,1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
frontport = FrontPort.objects.first()
interface = Interface.objects.create(
device=frontport.device,
name='Interface 1'
)
Cable(termination_a=frontport, termination_b=interface).save()
response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
self.assertHttpStatus(response, 200)
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort
@@ -1548,7 +1600,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
RearPort(device=device, name='Rear Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1580,6 +1632,18 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Rear Port 6,8p8c,1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
rearport = RearPort.objects.first()
interface = Interface.objects.create(
device=rearport.device,
name='Interface 1'
)
Cable(termination_a=rearport, termination_b=interface).save()
response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
self.assertHttpStatus(response, 200)
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay
@@ -1597,7 +1661,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
DeviceBay(device=device, name='Device Bay 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1637,7 +1701,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
InventoryItem.objects.create(device=device, name='Inventory Item 2')
InventoryItem.objects.create(device=device, name='Inventory Item 3')
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
@@ -1727,7 +1791,7 @@ class CableTestCase(
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = {
@@ -1854,7 +1918,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'site': sites[1].pk,
@@ -1902,7 +1966,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Power Feed X',
@@ -1938,3 +2002,26 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'max_utilization': 50,
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role', slug='device-role-1'
)
device = Device.objects.create(
site=Site.objects.first(), device_type=device_type, device_role=device_role
)
powerfeed = PowerFeed.objects.first()
powerport = PowerPort.objects.create(
device=device,
name='Power Port 1'
)
Cable(termination_a=powerfeed, termination_b=powerport).save()
response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
self.assertHttpStatus(response, 200)

View File

@@ -2,6 +2,7 @@ from django.urls import path
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from utilities.views import SlugRedirectView
from . import views
from .models import *
@@ -37,6 +38,7 @@ urlpatterns = [
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path('sites/<int:pk>/', views.SiteView.as_view(), name='site'),
path('sites/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Site}),
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
@@ -53,6 +55,7 @@ urlpatterns = [
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -415,6 +418,7 @@ urlpatterns = [
path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),

View File

@@ -338,12 +338,18 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
#
# Rack groups
# Locations
#
class LocationListView(generic.ObjectListView):
queryset = Location.objects.add_related_count(
Location.objects.all(),
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
@@ -1166,14 +1172,20 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
class DeviceRoleBulkEditView(generic.BulkEditView):
queryset = DeviceRole.objects.all()
queryset = DeviceRole.objects.annotate(
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filters.DeviceRoleFilterSet
table = tables.DeviceRoleTable
form = forms.DeviceRoleBulkEditForm
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.all()
queryset = DeviceRole.objects.annotate(
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
table = tables.DeviceRoleTable
@@ -2830,6 +2842,8 @@ class PowerPanelView(generic.ObjectView):
data=power_feeds,
orderable=False
)
if request.user.has_perm('dcim.delete_cable'):
powerfeed_table.columns.show('pk')
powerfeed_table.exclude = ['power_panel']
return {
@@ -2906,6 +2920,10 @@ class PowerFeedBulkEditView(generic.BulkEditView):
form = forms.PowerFeedBulkEditForm
class PowerFeedBulkDisconnectView(BulkDisconnectView):
queryset = PowerFeed.objects.all()
class PowerFeedBulkDeleteView(generic.BulkDeleteView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filterset = filters.PowerFeedFilterSet

View File

@@ -1,8 +1,10 @@
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
from utilities.utils import content_type_name
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
from .utils import FeatureQuery
@@ -110,7 +112,8 @@ class CustomFieldAdmin(admin.ModelAdmin):
)
def models(self, obj):
return ', '.join([ct.name for ct in obj.content_types.all()])
ct_names = [content_type_name(ct) for ct in obj.content_types.all()]
return mark_safe('<br/>'.join(ct_names))
#

View File

@@ -90,6 +90,7 @@ class CustomFieldModelFilterSet(django_filters.FilterSet):
class CustomFieldFilterSet(django_filters.FilterSet):
content_types = ContentTypeFilter()
class Meta:
model = CustomField

View File

@@ -18,6 +18,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(primary_key=True, serialize=False)),
('assigned_object_id', models.PositiveIntegerField()),
('created', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('kind', models.CharField(default='info', max_length=30)),
('comments', models.TextField()),
('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),

View File

@@ -114,6 +114,24 @@ class CustomField(BigIDModel):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache instance's original name so we can check later whether it has changed
self._name = self.name
def rename_object_data(self, old_name, new_name):
"""
Called when a CustomField has been renamed. Updates all assigned object data.
"""
for ct in self.content_types.all():
model = ct.model_class()
params = {f'custom_field_data__{old_name}__isnull': False}
instances = model.objects.filter(**params)
for instance in instances:
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
def remove_stale_data(self, content_types):
"""
Delete custom field data which is no longer relevant (either because the CustomField is

View File

@@ -7,13 +7,15 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format, time_format
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
from extras.constants import *
from extras.utils import extras_features, FeatureQuery, image_upload
from netbox.models import BigIDModel
from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@@ -151,7 +153,7 @@ class Webhook(BigIDModel):
ret = {}
data = render_jinja2(self.additional_headers, context)
for line in data.splitlines():
header, value = line.split(':')
header, value = line.split(':', 1)
ret[header.strip()] = value.strip()
return ret
@@ -389,7 +391,9 @@ class ImageAttachment(BigIDModel):
# Journal entries
#
class JournalEntry(BigIDModel):
@extras_features('webhooks')
class JournalEntry(ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@@ -427,8 +431,10 @@ class JournalEntry(BigIDModel):
verbose_name_plural = 'journal entries'
def __str__(self):
time_created = self.created.replace(microsecond=0)
return f"{time_created} - {self.get_kind_display()}"
return f"{date_format(self.created)} - {time_format(self.created)} ({self.get_kind_display()})"
def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk])
def get_kind_class(self):
return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)

View File

@@ -4,7 +4,8 @@ from datetime import timedelta
from cacheops.signals import cache_invalidated, cache_read
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import m2m_changed, pre_delete
from django.db import DEFAULT_DB_ALIAS
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
from prometheus_client import Counter
@@ -22,23 +23,35 @@ 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
m2m_changed = False
# Determine the type of change being made
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
m2m_changed = True
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()
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
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)
@@ -52,7 +65,7 @@ def _handle_changed_object(request, sender, instance, **kwargs):
# 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()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
def _handle_deleted_object(request, sender, instance, **kwargs):
@@ -85,6 +98,14 @@ def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
def handle_cf_renamed(instance, created, **kwargs):
"""
Handle the renaming of custom field data on objects when a CustomField is renamed.
"""
if not created and instance.name != instance._name:
instance.rename_object_data(old_name=instance._name, new_name=instance.name)
def handle_cf_deleted(instance, **kwargs):
"""
Handle the cleanup of old custom field data when a CustomField is deleted.
@@ -93,6 +114,7 @@ def handle_cf_deleted(instance, **kwargs):
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
post_save.connect(handle_cf_renamed, sender=CustomField)
pre_delete.connect(handle_cf_deleted, sender=CustomField)

View File

@@ -102,15 +102,15 @@ class ObjectJournalTable(BaseTable):
Used for displaying a set of JournalEntries within the context of a single object.
"""
created = tables.DateTimeColumn(
linkify=True,
format=settings.SHORT_DATETIME_FORMAT
)
kind = ChoiceFieldColumn()
comments = tables.TemplateColumn(
template_code='{% load helpers %}{{ value|render_markdown }}'
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
)
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
model=JournalEntry
)
class Meta(BaseTable.Meta):
@@ -128,9 +128,6 @@ class JournalEntryTable(ObjectJournalTable):
orderable=False,
verbose_name='Object'
)
comments = tables.TemplateColumn(
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
)
class Meta(BaseTable.Meta):
model = JournalEntry

View File

@@ -7,7 +7,7 @@ from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, ObjectChange, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import post_data
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase
@@ -38,7 +38,7 @@ class ChangeLogViewTest(ModelViewTestCase):
cf_select.content_types.set([ct])
def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2')
tags = create_tags('Tag 1', 'Tag 2')
form_data = {
'name': 'Site 1',
'slug': 'site-1',
@@ -56,24 +56,23 @@ class ChangeLogViewTest(ModelViewTestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Verify the creation of a new ObjectChange record
site = Site.objects.get(name='Site 1')
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
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].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
site.save()
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
tags = create_tags('Tag 1', 'Tag 2', 'Tag 3')
site.tags.set('Tag 1', 'Tag 2')
form_data = {
@@ -93,8 +92,8 @@ class ChangeLogViewTest(ModelViewTestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Verify the creation of a new ObjectChange record
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
@@ -117,7 +116,7 @@ class ChangeLogViewTest(ModelViewTestCase):
}
)
site.save()
self.create_tags('Tag 1', 'Tag 2')
create_tags('Tag 1', 'Tag 2')
site.tags.set('Tag 1', 'Tag 2')
request = {
@@ -259,17 +258,15 @@ class ChangeLogAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
site = Site.objects.get(pk=response.data['id'])
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
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].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
@@ -294,11 +291,10 @@ class ChangeLogAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
site = Site.objects.get(pk=response.data['id'])
# Get only the most recent OC
oc = ObjectChange.objects.filter(
oc = ObjectChange.objects.get(
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.postchange_data['custom_fields'], data['custom_fields'])

View File

@@ -91,6 +91,33 @@ class CustomFieldTest(TestCase):
# Delete the custom field
cf.delete()
def test_rename_customfield(self):
obj_type = ContentType.objects.get_for_model(Site)
FIELD_DATA = 'abc'
# Create a custom field
cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
cf.save()
cf.content_types.set([obj_type])
# Assign custom field data to an object
site = Site.objects.create(
name='Site 1',
slug='site-1',
custom_field_data={'field1': FIELD_DATA}
)
site.refresh_from_db()
self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
# Rename the custom field
cf.name = 'field2'
cf.save()
# Check that custom field data on the object has been updated
site.refresh_from_db()
self.assertNotIn('field1', site.custom_field_data)
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
class CustomFieldManagerTest(TestCase):

View File

@@ -2,7 +2,7 @@ from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from utilities.testing import APITestCase
from utilities.testing import APITestCase, create_tags
class TaggedItemTest(APITestCase):
@@ -10,7 +10,7 @@ class TaggedItemTest(APITestCase):
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
"""
def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
tags = create_tags("Foo", "Bar", "Baz")
data = {
'name': 'Test Site',
'slug': 'test-site',
@@ -37,7 +37,7 @@ class TaggedItemTest(APITestCase):
slug='test-site'
)
site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
create_tags("New Tag")
data = {
'tags': [
{"name": "Foo"},

View File

@@ -1,7 +1,7 @@
from django.urls import path
from extras import views
from extras.models import ConfigContext, Tag
from extras.models import ConfigContext, JournalEntry, Tag
app_name = 'extras'
@@ -37,8 +37,10 @@ urlpatterns = [
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),

View File

@@ -306,6 +306,10 @@ class JournalEntryListView(generic.ObjectListView):
action_buttons = ('export',)
class JournalEntryView(generic.ObjectView):
queryset = JournalEntry.objects.all()
class JournalEntryEditView(generic.ObjectEditView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryForm

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "ipam"
verbose_name = "IPAM"
def ready(self):
import ipam.signals

21
netbox/ipam/signals.py Normal file
View File

@@ -0,0 +1,21 @@
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from dcim.models import Device
from virtualization.models import VirtualMachine
from .models import IPAddress
@receiver(pre_delete, sender=IPAddress)
def clear_primary_ip(instance, **kwargs):
"""
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
was a primary IP.
"""
field_name = f'primary_ip{instance.family}'
device = Device.objects.filter(**{field_name: instance}).first()
if device:
device.save()
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
if virtualmachine:
virtualmachine.save()

View File

@@ -6,7 +6,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from tenancy.models import Tenant
from utilities.testing import ViewTestCases
from utilities.testing import ViewTestCases, create_tags
class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -27,7 +27,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
VRF(name='VRF 3', rd='65000:3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'VRF X',
@@ -64,7 +64,7 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Tenant.objects.bulk_create(tenants)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
route_targets = (
RouteTarget(name='65000:1001', tenant=tenants[0]),
@@ -141,7 +141,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'prefix': IPNetwork('10.99.0.0/16'),
@@ -226,7 +226,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'),
@@ -277,7 +277,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'vrf': vrfs[1].pk,
@@ -374,7 +374,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'site': sites[1].pk,
@@ -434,7 +434,7 @@ class ServiceTestCase(
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,

View File

@@ -34,6 +34,9 @@ REDIS = {
'PASSWORD': '',
'DATABASE': 0,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
},
'caching': {
'HOST': 'localhost',
@@ -44,6 +47,9 @@ REDIS = {
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
}
}

View File

@@ -195,6 +195,15 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTMo
def __str__(self):
return self.name
def clean(self):
super().clean()
# An MPTT model cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": "Cannot assign self as parent."
})
class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel):
"""

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.11-beta1'
VERSION = '2.11.0'
# Hostname
HOSTNAME = platform.node()
@@ -221,6 +221,7 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
# Caching
if 'caching' not in REDIS:
@@ -239,6 +240,7 @@ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default'
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
#
@@ -406,21 +408,14 @@ if CACHING_REDIS_USING_SENTINEL:
'password': CACHING_REDIS_PASSWORD,
}
else:
if CACHING_REDIS_SSL:
REDIS_CACHE_CON_STRING = 'rediss://'
else:
REDIS_CACHE_CON_STRING = 'redis://'
if CACHING_REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST,
CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE
)
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
CACHEOPS_REDIS = {
'host': CACHING_REDIS_HOST,
'port': CACHING_REDIS_PORT,
'db': CACHING_REDIS_DATABASE,
'password': CACHING_REDIS_PASSWORD,
'ssl': CACHING_REDIS_SSL,
'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required',
}
if not CACHE_TIMEOUT:
CACHEOPS_ENABLED = False
@@ -568,6 +563,7 @@ else:
'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD,
'SSL': TASKS_REDIS_SSL,
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
}

View File

@@ -63,18 +63,16 @@ _patterns = [
# Serving static media in Django to pipe it through LoginRequiredMiddleware
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
# Admin
path('admin/', admin_site.urls),
path('admin/background-tasks/', include('django_rq.urls')),
# Errors
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
# Plugins
path('plugins/', include((plugin_patterns, 'plugins'))),
path('api/plugins/', include((plugin_api_patterns, 'plugins-api'))),
path('admin/plugins/', include(plugin_admin_patterns))
# Admin
path('admin/background-tasks/', include('django_rq.urls')),
path('admin/plugins/', include(plugin_admin_patterns)),
path('admin/', admin_site.urls),
]

View File

@@ -16,7 +16,7 @@ from packaging import version
from circuits.models import Circuit, Provider
from dcim.models import (
Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
Cable, ConsolePort, Device, DeviceType, Interface, Location, PowerPanel, PowerFeed, PowerPort, Rack, Site,
)
from extras.choices import JobResultStatusChoices
from extras.models import ObjectChange, JobResult
@@ -56,6 +56,7 @@ class HomeView(View):
# Organization
'site_count': Site.objects.restrict(request.user, 'view').count(),
'location_count': Location.objects.restrict(request.user, 'view').count(),
'tenant_count': Tenant.objects.restrict(request.user, 'view').count(),
# DCIM

View File

@@ -182,7 +182,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
if request.GET.get('export') == 'table':
exclude_columns = {'pk'}
exclude_columns.update({
col for col in table.base_columns if col not in table.visible_columns
name for name, _ in table.available_columns
})
exporter = TableExport(
export_format=TableExport.CSV,

View File

@@ -362,6 +362,9 @@ table.report th a {
border-radius: .25em;
vertical-align: middle;
}
h1.title {
margin-top: 0;
}
.text-nowrap {
white-space: nowrap;
}

View File

@@ -1,9 +1,27 @@
$(document).ready(function() {
$('form.userconfigform input.reset').click(function(event) {
// Deselect all columns when the reset button is clicked
// Select or reset table columns
$('#save_tableconfig').click(function(event) {
$('select[name="columns"] option').attr("selected", "selected");
});
$('#reset_tableconfig').click(function(event) {
$('select[name="columns"]').val([]);
});
// Swap columns between available and selected lists
$('#add_columns').click(function(e) {
let selected_columns = $('#id_available_columns option:selected');
$('#id_columns').append($(selected_columns).clone());
$(selected_columns).remove();
e.preventDefault();
});
$('#remove_columns').click(function(e) {
let selected_columns = $('#id_columns option:selected');
$('#id_available_columns').append($(selected_columns).clone());
$(selected_columns).remove();
e.preventDefault();
});
$('form.userconfigform').submit(function(event) {
event.preventDefault();

View File

@@ -40,6 +40,12 @@
{% endif %}
</td>
</tr>
<tr>
<td>Racks</td>
<td>
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ object.racks.count }}</a>
</td>
</tr>
<tr>
<td>Devices</td>
<td>
@@ -52,6 +58,20 @@
</div>
<div class="col-md-6">
{% include 'inc/custom_fields_panel.html' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Images</strong>
</div>
{% include 'inc/image_attachments.html' with images=object.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:location_add_image' object_id=object.pk %}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Attach an image
</a>
</div>
{% endif %}
</div>
{% plugin_right_page object %}
</div>
</div>

View File

@@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
<li><a href="{% url 'dcim:powerpanel_list' %}">Power Panels</a></li>
@@ -47,7 +48,37 @@
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %}
<form method="post">
{% csrf_token %}
<div class="panel panel-default">
{% render_table powerfeed_table 'inc/table.html' %}
<div class="panel-footer noprint">
{% if perms.dcim.change_powerfeed %}
<button type="submit" name="_edit" formaction="{% url 'dcim:powerfeed_bulk_edit' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-warning btn-xs">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if perms.dcim.delete_cable %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerfeed_bulk_disconnect' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger btn-xs">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if perms.dcim.delete_powerfeed %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerfeed_bulk_delete' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger btn-xs">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_powerfeed %}
<div class="pull-right">
<a href="{% url 'dcim:powerfeed_add' %}?power_panel={{ object.pk }}&return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add power feeds
</a>
</div>
{% endif %}
<div class="clearfix"></div>
</div>
</div>
</form>
{% plugin_full_width_page object %}
</div>
</div>

View File

@@ -0,0 +1,57 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load static %}
{% block breadcrumbs %}
<li><a href="{% url 'extras:journalentry_list' %}">Journal Entries</a></li>
<li><a href="{% url object.assigned_object|viewname:'journal' pk=object.assigned_object.pk %}">{{ object.assigned_object }}</a></li>
<li>{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Journal Entry</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Object</td>
<td>
<a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}</a>
</td>
</tr>
<tr>
<td>Created</td>
<td>
{{ object.created }}
</td>
</tr>
<tr>
<td>Created By</td>
<td>
{{ object.created_by }}
</td>
</tr>
<tr>
<td>Kind</td>
<td>
<span class="label label-{{ object.get_kind_class }}">{{ object.get_kind_display }}</span>
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
{{ object.comments|render_markdown }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -27,7 +27,7 @@
</form>
</div>
{% endif %}
<h1>{{ report.name }}</h1>
<h1 class="title">{{ report.name }}</h1>
{% if report.description %}
<p class="lead">{{ report.description }}</p>
{% endif %}

View File

@@ -66,9 +66,11 @@
<a href="{{ url }}">{{ obj }}</a>
{% elif obj %}
{{ obj }}
{% else %}
<span class="muted">&mdash;</span>
{% endif %}
</td>
<td>{{ message }}</td>
<td class="rendered-markdown">{{ message|render_markdown }}</td>
</tr>
{% endfor %}
{% endfor %}

View File

@@ -15,7 +15,7 @@
</ol>
</div>
</div>
<h1>{{ script }}</h1>
<h1 class="title">{{ script }}</h1>
<p>{{ script.Meta.description|render_markdown }}</p>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">

View File

@@ -17,7 +17,7 @@
</ol>
</div>
</div>
<h1>{{ script }}</h1>
<h1 class="title">{{ script }}</h1>
<p>{{ script.Meta.description|render_markdown }}</p>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">

View File

@@ -39,8 +39,14 @@
{% endif %}
{% endblock %}
</div>
<h1>{% block title %}{{ object }}{% endblock %}</h1>
{% include 'inc/created_updated.html' %}
<h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
<p>
<small class="text-muted">
Created {{ object.created }} &middot;
Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago
</small>
<span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
</p>
<div class="pull-right noprint">
{% block tab_buttons %}
{% custom_links object %}

View File

@@ -1,6 +1,7 @@
{% extends 'base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load static %}
{% block content %}
@@ -29,52 +30,54 @@
</div>
{% endif %}
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{% if table.paginator.num_pages > 1 %}
<div id="select_all_box" class="hidden panel panel-default noprint">
<div class="panel-body">
<div class="checkbox-inline">
<label for="select_all">
<input type="checkbox" id="select_all" name="_all" />
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
<div class="pull-right">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{% if table.paginator.num_pages > 1 %}
<div id="select_all_box" class="hidden panel panel-default noprint">
<div class="panel-body">
<div class="checkbox-inline">
<label for="select_all">
<input type="checkbox" id="select_all" name="_all" />
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
<div class="pull-right">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% endif %}
{% include table_template|default:'responsive_table.html' %}
<div class="pull-left noprint">
{% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
</div>
</form>
{% else %}
{% include table_template|default:'responsive_table.html' %}
{% endif %}
<div class="pull-left noprint">
{% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
</div>
</form>
{% else %}
{% render_table table 'inc/table.html' %}
{% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>

View File

@@ -31,7 +31,17 @@
<span class="badge pull-right"><i class="mdi mdi-lock"></i></span>
<h4 class="list-group-item-heading">Sites</h4>
{% endif %}
<p class="list-group-item-text text-muted">Geographic locations</p>
<p class="list-group-item-text text-muted">Discrete points of presence</p>
</div>
<div class="list-group-item">
{% if perms.dcim.view_location %}
<span class="badge pull-right">{{ stats.location_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'dcim:location_list' %}">Locations</a></h4>
{% else %}
<span class="badge pull-right"><i class="mdi mdi-lock"></i></span>
<h4 class="list-group-item-heading">Locations</h4>
{% endif %}
<p class="list-group-item-text text-muted">Locations within sites</p>
</div>
<div class="list-group-item">
{% if perms.tenancy.view_tenant %}

View File

@@ -1,3 +0,0 @@
<p>
<small class="text-muted">Created {{ object.created }} &middot; Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
</p>

View File

@@ -1,14 +0,0 @@
<div class="pull-right">
{% if perms.ipam.add_vlan and first_available_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ first_available_vlan }}&group={{ object.pk }}{% if object.site %}&site={{ object.site.pk }}{% endif %}" class="btn btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a VLAN
</a>
{% endif %}
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=object.pk %}" class="btn btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span>
Edit this VLAN Group
</a>
{% endif %}
</div>
<h1>{{ object }}</h1>

View File

@@ -18,7 +18,9 @@
<span class="label label-danger">Inactive</span>
{% endif %}
</h4>
{% include 'inc/created_updated.html' %}
<p>
<small class="text-muted">Created {{ object.created }} &middot; Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
</p>
{% if not object.is_active %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i>

View File

@@ -8,17 +8,23 @@
</div>
<div class="modal-body">
<form class="form-horizontal userconfigform" data-config-root="tables.{{ table_config_form.table_name }}">
{% render_form table_config_form %}
<div class="row">
{% render_field table_config_form.available_columns %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<a class="btn btn-success btn-xs" id="add_columns"><i class="mdi mdi-arrow-down-bold"></i> Add columns</a>
<a class="btn btn-danger btn-xs" id="remove_columns"><i class="mdi mdi-arrow-up-bold"></i> Remove columns</a>
</div>
</div>
{% render_field table_config_form.columns %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<a class="btn btn-primary btn-xs" id="move-option-up" data-target="id_columns"><i class="mdi mdi-arrow-up-bold"></i> Move up</a>
<a class="btn btn-primary btn-xs" id="move-option-down" data-target="id_columns"><i class="mdi mdi-arrow-down-bold"></i> Move down</a>
<a class="btn btn-success btn-xs" id="select-all-options" data-target="id_columns"><i class="mdi mdi-expand-all-outline"></i> Select all</a>
</div>
</div>
<div class="text-right">
<input type="submit" class="btn btn-primary" value="Save" />
<input type="submit" class="btn btn-danger reset" value="Reset" />
<input type="submit" class="btn btn-primary" id="save_tableconfig" value="Save" />
<input type="submit" class="btn btn-danger" id="reset_tableconfig" value="Reset" />
</div>
</form>
</div>

View File

@@ -12,7 +12,7 @@
{% block buttons %}
{% if perms.virtualization.add_vminterface %}
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary">
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Interfaces
</a>
{% endif %}

View File

@@ -35,7 +35,7 @@
{% endif %}
{% if perms.virtualization.add_vminterface %}
<div class="pull-right">
<a href="{% url 'virtualization:vminterface_add' %}?virtualmachine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary btn-xs">
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add interfaces
</a>
</div>

View File

@@ -39,6 +39,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Parent</td>
<td>
{% if object.parent %}
<a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ object.description|placeholder }} </td>
@@ -91,9 +101,14 @@
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page object %}
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -17,6 +17,7 @@
{% endif %}
{% render_field form.name %}
{% render_field form.enabled %}
{% render_field form.parent %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% render_field form.description %}

View File

@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey

View File

@@ -1,5 +1,5 @@
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ViewTestCases
from utilities.testing import ViewTestCases, create_tags
class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -53,7 +53,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Tenant X',

View File

@@ -1,6 +1,7 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from utilities.views import SlugRedirectView
from . import views
from .models import Tenant, TenantGroup
@@ -25,6 +26,7 @@ urlpatterns = [
path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
path('tenants/<int:pk>/', views.TenantView.as_view(), name='tenant'),
path('tenants/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Tenant}),
path('tenants/<int:pk>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
path('tenants/<int:pk>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),

View File

@@ -13,6 +13,7 @@ from django.forms import BoundField
from django.urls import reverse
from utilities.choices import unpack_grouped_choices
from utilities.utils import content_type_name
from utilities.validators import EnhancedURLValidator
from . import widgets
from .constants import *
@@ -124,8 +125,7 @@ class ContentTypeChoiceMixin:
def label_from_instance(self, obj):
try:
meta = obj.model_class()._meta
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
return content_type_name(obj)
except AttributeError:
return super().label_from_instance(obj)

View File

@@ -161,13 +161,21 @@ class TableConfigForm(BootstrapMixin, forms.Form):
"""
Form for configuring user's table preferences.
"""
available_columns = forms.MultipleChoiceField(
choices=[],
required=False,
widget=forms.SelectMultiple(
attrs={'size': 10}
),
label='Available columns'
)
columns = forms.MultipleChoiceField(
choices=[],
required=False,
widget=forms.SelectMultiple(
attrs={'size': 10}
),
help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
label='Selected columns'
)
def __init__(self, table, *args, **kwargs):
@@ -176,8 +184,8 @@ class TableConfigForm(BootstrapMixin, forms.Form):
super().__init__(*args, **kwargs)
# Initialize columns field based on table attributes
self.fields['columns'].choices = table.configurable_columns
self.fields['columns'].initial = table.visible_columns
self.fields['available_columns'].choices = table.available_columns
self.fields['columns'].choices = table.selected_columns
@property
def table_name(self):

View File

@@ -15,15 +15,16 @@ from extras.models import CustomField
from .paginator import EnhancedPaginator, get_paginate_count
def stripped_value(self, value):
def stripped_value(self, **kwargs):
"""
Replaces TemplateColumn's value() method to both strip HTML tags and remove any leading/trailing whitespace.
"""
return strip_tags(value).strip()
html = super(tables.TemplateColumn, self).value(**kwargs)
return strip_tags(html).strip() if isinstance(html, str) else html
# TODO: We're monkey-patching TemplateColumn here to strip leading/trailing whitespace. This will no longer
# be necessary if django-tables2 PR #794 is accepted. (See #5926)
# be necessary under django-tables2 v2.3.5+. (See #5926)
tables.TemplateColumn.value = stripped_value
@@ -106,19 +107,20 @@ class BaseTable(tables.Table):
prefetch_fields.append('__'.join(prefetch_path))
self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
@property
def configurable_columns(self):
selected_columns = [
(name, self.columns[name].verbose_name) for name in self.sequence if name not in ['pk', 'actions']
]
available_columns = [
(name, column.verbose_name) for name, column in self.columns.items() if name not in self.sequence and name not in ['pk', 'actions']
]
return selected_columns + available_columns
def _get_columns(self, visible=True):
columns = []
for name, column in self.columns.items():
if column.visible == visible and name not in ['pk', 'actions']:
columns.append((name, column.verbose_name))
return columns
@property
def visible_columns(self):
return [name for name in self.sequence if self.columns[name].visible]
def available_columns(self):
return self._get_columns(visible=False)
@property
def selected_columns(self):
return self._get_columns(visible=True)
#

View File

@@ -1,3 +1,4 @@
from .api import *
from .base import *
from .utils import *
from .views import *

View File

@@ -6,9 +6,11 @@ from django.test import override_settings
from rest_framework import status
from rest_framework.test import APIClient
from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from users.models import ObjectPermission, Token
from .base import ModelTestCase
from .utils import disable_warnings
from .views import ModelTestCase
__all__ = (
@@ -223,13 +225,23 @@ class APIViewTestCases:
response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(self._get_queryset().count(), initial_count + 1)
instance = self._get_queryset().get(pk=response.data['id'])
self.assertInstanceEqual(
self._get_queryset().get(pk=response.data['id']),
instance,
self.create_data[0],
exclude=self.validation_excluded_fields,
api=True
)
# Verify ObjectChange creation
if hasattr(self.model, 'to_objectchange'):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
def test_bulk_create_objects(self):
"""
POST a set of objects in a single request.
@@ -304,6 +316,15 @@ class APIViewTestCases:
api=True
)
# Verify ObjectChange creation
if hasattr(self.model, 'to_objectchange'):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
def test_bulk_update_objects(self):
"""
PATCH a set of objects in a single request.
@@ -367,6 +388,15 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
# Verify ObjectChange creation
if hasattr(self.model, 'to_objectchange'):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
def test_bulk_delete_objects(self):
"""
DELETE a set of objects in a single request.

View File

@@ -0,0 +1,161 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import FieldDoesNotExist
from django.db.models import ManyToManyField
from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase
from netaddr import IPNetwork
from taggit.managers import TaggableManager
from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct
from .utils import extract_form_failures
__all__ = (
'ModelTestCase',
'TestCase',
)
class TestCase(_TestCase):
user_permissions = ()
def setUp(self):
# Create the test user and assign permissions
self.user = User.objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions)
# Initialize the test client
self.client = Client()
self.client.force_login(self.user)
#
# Permissions management
#
def add_permissions(self, *names):
"""
Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
"""
for name in names:
ct, action = resolve_permission_ct(name)
obj_perm = ObjectPermission(name=name, actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ct)
#
# Custom assertions
#
def assertHttpStatus(self, response, expected_status):
"""
TestCase method. Provide more detail in the event of an unexpected HTTP response.
"""
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)
class ModelTestCase(TestCase):
"""
Parent class for TestCases which deal with models.
"""
model = None
def _get_queryset(self):
"""
Return a base queryset suitable for use in test methods.
"""
return self.model.objects.all()
def prepare_instance(self, instance):
"""
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
"""
return instance
def model_to_dict(self, instance, fields, api=False):
"""
Return a dictionary representation of an instance.
"""
# Prepare the instance and call Django's model_to_dict() to extract all fields
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
# Map any additional (non-field) instance attributes that were specified
for attr in fields:
if hasattr(instance, attr) and attr not in model_dict:
model_dict[attr] = getattr(instance, attr)
for key, value in list(model_dict.items()):
try:
field = instance._meta.get_field(key)
except FieldDoesNotExist:
# Attribute is not a model field
continue
# Handle ManyToManyFields
if value and type(field) in (ManyToManyField, TaggableManager):
if field.related_model is ContentType:
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
else:
model_dict[key] = sorted([obj.pk for obj in value])
if api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
model_dict[key] = f'{ct.app_label}.{ct.model}'
# Convert IPNetwork instances to strings
elif type(value) is IPNetwork:
model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
return model_dict
#
# Custom assertions
#
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary.
:param instance: Python object instance
:param data: Dictionary of test data used to define the instance
:param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
:param api: Set to True is the data is a JSON representation of the instance
"""
if exclude is None:
exclude = []
fields = [k for k in data.keys() if k not in exclude]
model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
}
self.assertDictEqual(model_dict, relevant_data)

View File

@@ -3,6 +3,10 @@ import re
from contextlib import contextmanager
from django.contrib.auth.models import Permission, User
from django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.models import Tag
def post_data(data):
@@ -29,6 +33,19 @@ def post_data(data):
return ret
def create_test_device(name):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
return device
def create_test_user(username='testuser', permissions=None):
"""
Create a User with the given permissions.
@@ -44,6 +61,15 @@ def create_test_user(username='testuser', permissions=None):
return user
def create_tags(*names):
"""
Create and return a Tag instance for each name given.
"""
tags = [Tag(name=name, slug=slugify(name)) for name in names]
Tag.objects.bulk_create(tags)
return tags
def extract_form_failures(content):
"""
Given raw HTML content from an HTTP response, return a list of form errors.

View File

@@ -1,181 +1,20 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db.models import ManyToManyField
from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from django.urls import reverse
from django.utils.text import slugify
from netaddr import IPNetwork
from taggit.managers import TaggableManager
from extras.models import Tag
from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct
from .utils import disable_warnings, extract_form_failures, post_data
from .base import ModelTestCase
from .utils import disable_warnings, post_data
__all__ = (
'TestCase',
'ModelTestCase',
'ModelViewTestCase',
'ViewTestCases',
)
class TestCase(_TestCase):
user_permissions = ()
def setUp(self):
# Create the test user and assign permissions
self.user = User.objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions)
# Initialize the test client
self.client = Client()
self.client.force_login(self.user)
def prepare_instance(self, instance):
"""
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
"""
return instance
def model_to_dict(self, instance, fields, api=False):
"""
Return a dictionary representation of an instance.
"""
# Prepare the instance and call Django's model_to_dict() to extract all fields
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
# Map any additional (non-field) instance attributes that were specified
for attr in fields:
if hasattr(instance, attr) and attr not in model_dict:
model_dict[attr] = getattr(instance, attr)
for key, value in list(model_dict.items()):
try:
field = instance._meta.get_field(key)
except FieldDoesNotExist:
# Attribute is not a model field
continue
# Handle ManyToManyFields
if value and type(field) in (ManyToManyField, TaggableManager):
if field.related_model is ContentType:
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
else:
model_dict[key] = sorted([obj.pk for obj in value])
if api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
model_dict[key] = f'{ct.app_label}.{ct.model}'
# Convert IPNetwork instances to strings
elif type(value) is IPNetwork:
model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
return model_dict
#
# Permissions management
#
def add_permissions(self, *names):
"""
Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
"""
for name in names:
ct, action = resolve_permission_ct(name)
obj_perm = ObjectPermission(name=name, actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ct)
#
# Custom assertions
#
def assertHttpStatus(self, response, expected_status):
"""
TestCase method. Provide more detail in the event of an unexpected HTTP response.
"""
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, exclude=None, api=False):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary.
:param instance: Python object instance
:param data: Dictionary of test data used to define the instance
:param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
:param api: Set to True is the data is a JSON representation of the instance
"""
if exclude is None:
exclude = []
fields = [k for k in data.keys() if k not in exclude]
model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
}
self.assertDictEqual(model_dict, relevant_data)
#
# Convenience methods
#
@classmethod
def create_tags(cls, *names):
"""
Create and return a Tag instance for each name given.
"""
tags = [Tag(name=name, slug=slugify(name)) for name in names]
Tag.objects.bulk_create(tags)
return tags
class ModelTestCase(TestCase):
"""
Parent class for TestCases which deal with models.
"""
model = None
def _get_queryset(self):
"""
Return a base queryset suitable for use in test methods.
"""
return self.model.objects.all()
#
# UI Tests
#
@@ -184,7 +23,6 @@ class ModelViewTestCase(ModelTestCase):
"""
Base TestCase for model views. Subclass to test individual views.
"""
def _get_base_url(self):
"""
Return the base format for a URL for the test's model. Override this to test for a model which belongs
@@ -323,7 +161,16 @@ class ViewTestCases:
}
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(initial_count + 1, self._get_queryset().count())
self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data)
instance = self._get_queryset().order_by('pk').last()
self.assertInstanceEqual(instance, self.form_data)
# Verify ObjectChange creation
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_create_object_with_constrained_permission(self):
@@ -410,6 +257,14 @@ class ViewTestCases:
self.assertHttpStatus(self.client.post(**request), 302)
self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data)
# Verify ObjectChange creation
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_edit_object_with_constrained_permission(self):
instance1, instance2 = self._get_queryset().all()[:2]
@@ -489,6 +344,14 @@ class ViewTestCases:
with self.assertRaises(ObjectDoesNotExist):
self._get_queryset().get(pk=instance.pk)
# Verify ObjectChange creation
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_delete_object_with_constrained_permission(self):
instance1, instance2 = self._get_queryset().all()[:2]

View File

@@ -226,12 +226,12 @@ def prepare_cloned_fields(instance):
field = instance._meta.get_field(field_name)
field_value = field.value_from_object(instance)
# Swap out False with URL-friendly value
# Pass False as null for boolean fields
if field_value is False:
field_value = ''
params.append((field_name, ''))
# Omit empty values
if field_value not in (None, ''):
elif field_value not in (None, ''):
params.append((field_name, field_value))
# Copy tags
@@ -296,6 +296,14 @@ def array_to_string(array):
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
def content_type_name(contenttype):
"""
Return a proper ContentType name.
"""
meta = contenttype.model_class()._meta
return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
#
# Fake request object
#

View File

@@ -1,8 +1,10 @@
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.http import is_safe_url
from django.views.generic import View
from .permissions import resolve_permission
@@ -123,3 +125,14 @@ class GetReturnURLMixin:
# If all else fails, return home. Ideally this should never happen.
return reverse('home')
#
# Views
#
class SlugRedirectView(View):
def get(self, request, model, slug):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), slug=slug)
return redirect(obj.get_absolute_url())

View File

@@ -106,6 +106,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class VMInterfaceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
virtual_machine = NestedVirtualMachineSerializer()
parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
@@ -114,12 +115,14 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
required=False,
many=True
)
count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta:
model = VMInterface
fields = [
'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description',
'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
'count_ipaddresses',
]
def validate(self, data):

View File

@@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
class VMInterfaceViewSet(ModelViewSet):
queryset = VMInterface.objects.prefetch_related(
'virtual_machine', 'tags', 'tagged_vlans'
'virtual_machine', 'parent', 'tags', 'tagged_vlans', 'ip_addresses'
)
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet

View File

@@ -264,6 +264,11 @@ class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpda
to_field_name='name',
label='Virtual machine',
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=VMInterface.objects.all(),
label='Parent interface (ID)',
)
mac_address = MultiValueMACAddressFilter(
label='MAC address',
)

View File

@@ -603,6 +603,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
#
class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Parent interface'
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
@@ -621,8 +626,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
class Meta:
model = VMInterface
fields = [
'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
'tagged_vlans',
'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
'untagged_vlan', 'tagged_vlans',
]
widgets = {
'virtual_machine': forms.HiddenInput(),
@@ -637,9 +642,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
# Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -655,6 +663,14 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
required=False,
initial=True
)
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
display_field='display_name',
query_params={
'virtualmachine_id': 'virtual_machine',
}
)
mtu = forms.IntegerField(
required=False,
min_value=INTERFACE_MTU_MIN,
@@ -686,12 +702,19 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
queryset=Tag.objects.all(),
required=False
)
field_order = (
'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
# Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -730,6 +753,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
disabled=True,
widget=forms.HiddenInput()
)
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
display_field='display_name'
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
@@ -760,16 +788,41 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class Meta:
nullable_fields = [
'mtu', 'description',
'parent', 'mtu', 'description',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'virtual_machine' in self.initial:
vm_id = self.initial.get('virtual_machine')
# Limit VLAN choices by virtual machine
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
# Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
else:
# See 5643
if 'pk' in self.initial:
site = None
interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
'virtual_machine__cluster__site'
)
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
for interface in interfaces:
if site is None:
site = interface.virtual_machine.cluster.site
elif interface.virtual_machine.cluster.site is not site:
site = None
break
if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
class VMInterfaceBulkRenameForm(BulkRenameForm):
@@ -779,7 +832,7 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
)
class VMInterfaceFilterForm(forms.Form):
class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
model = VMInterface
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0021_virtualmachine_vcpus_decimal'),
]
operations = [
migrations.AddField(
model_name='vminterface',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'),
),
]

View File

@@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface):
max_length=200,
blank=True
)
parent = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
related_name='child_interfaces',
null=True,
blank=True,
verbose_name='Parent interface'
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface):
self.virtual_machine.name,
self.name,
self.enabled,
self.parent.name if self.parent else None,
self.mac_address,
self.mtu,
self.description,
@@ -447,6 +456,17 @@ class VMInterface(PrimaryModel, BaseInterface):
def clean(self):
super().clean()
# An interface's parent must belong to the same virtual machine
if self.parent and self.parent.virtual_machine != self.virtual_machine:
raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
f"({self.parent.virtual_machine})."
})
# An interface cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({'parent': "An interface cannot be its own parent."})
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({
@@ -461,7 +481,3 @@ class VMInterface(PrimaryModel, BaseInterface):
@property
def parent_object(self):
return self.virtual_machine
@property
def count_ipaddresses(self):
return self.ip_addresses.count()

View File

@@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable):
name = tables.Column(
linkify=True
)
parent = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='virtualization:vminterface_list'
)
@@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = (
'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description')
class VirtualMachineVMInterfaceTable(VMInterfaceTable):

View File

@@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase):
params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned_to_interface(self):
def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_parent(self):
# Create child interfaces
parent_interface = VMInterface.objects.first()
child_interfaces = (
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface),
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface),
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface),
)
VMInterface.objects.bulk_create(child_interfaces)
params = {'parent_id': [parent_interface.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_mtu(self):
params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN
from utilities.testing import ViewTestCases
from utilities.testing import ViewTestCases, create_tags
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -100,7 +100,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Cluster X',
@@ -174,7 +174,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cluster': clusters[1].pk,
@@ -256,7 +256,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'virtual_machine': virtualmachines[1].pk,

View File

@@ -163,8 +163,6 @@ class ClusterVirtualMachinesView(generic.ObjectView):
def get_extra_context(self, request, instance):
virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance)
virtualmachines_table = tables.VirtualMachineTable(virtualmachines, orderable=False)
if request.user.has_perm('virtualization.change_cluster'):
virtualmachines_table.columns.show('pk')
return {
'virtualmachines_table': virtualmachines_table,
@@ -423,6 +421,14 @@ class VMInterfaceView(generic.ObjectView):
orderable=False
)
# Get child interfaces
child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.VMInterfaceTable(
child_interfaces,
orderable=False
)
child_interfaces_tables.columns.hide('virtual_machine')
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@@ -439,6 +445,7 @@ class VMInterfaceView(generic.ObjectView):
return {
'ipaddress_table': ipaddress_table,
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
}

View File

@@ -1,7 +1,7 @@
Django==3.2
django-cacheops==5.1
django-cors-headers==3.7.0
django-debug-toolbar==3.2
django-debug-toolbar==3.2.1
django-filter==2.4.0
django-mptt==0.12.0
django-pglocks==1.0.4