mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-14 12:57:44 +01:00
Compare commits
117 Commits
v2.11-beta
...
v2.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abaa5e60c | ||
|
|
f4792eea04 | ||
|
|
1fe1e1ce7e | ||
|
|
58659cf3b6 | ||
|
|
9a588231c5 | ||
|
|
f408ad16e4 | ||
|
|
fecca5ad83 | ||
|
|
9e5d41c48a | ||
|
|
87b4cfff1a | ||
|
|
19a2b9042f | ||
|
|
2618dde1e2 | ||
|
|
1dd9f8c1d4 | ||
|
|
4939b6b641 | ||
|
|
52747e364a | ||
|
|
1901e93b1e | ||
|
|
648b9dd7d8 | ||
|
|
396c91f8f7 | ||
|
|
83f520f7a8 | ||
|
|
d8ae65a762 | ||
|
|
efea511211 | ||
|
|
2564818c3e | ||
|
|
e6930d9601 | ||
|
|
4e405ce530 | ||
|
|
88ffc9b145 | ||
|
|
9ed76400de | ||
|
|
497e50c559 | ||
|
|
7cf9e202a3 | ||
|
|
620d222f98 | ||
|
|
a2d16143e3 | ||
|
|
97c087ef5f | ||
|
|
6bd4b3c167 | ||
|
|
89350a80ad | ||
|
|
f26227ecec | ||
|
|
c002501c2e | ||
|
|
8af78c6d84 | ||
|
|
7e8ffd8390 | ||
|
|
5bd30060e7 | ||
|
|
c24cac9a44 | ||
|
|
2bc9730b1d | ||
|
|
428858dc75 | ||
|
|
8a425a93ae | ||
|
|
0bb2a051b6 | ||
|
|
ef78a7d41b | ||
|
|
6c1c695616 | ||
|
|
8bdab347b6 | ||
|
|
ae349d4424 | ||
|
|
3e49745257 | ||
|
|
c2b7226205 | ||
|
|
f67369a9a9 | ||
|
|
14d2a49942 | ||
|
|
92df40a6a0 | ||
|
|
a2203da1c6 | ||
|
|
0fb8291b8b | ||
|
|
b393ec2856 | ||
|
|
664ba55460 | ||
|
|
3d0882856f | ||
|
|
e849d28276 | ||
|
|
18a691482d | ||
|
|
7c48e3632d | ||
|
|
8701b20899 | ||
|
|
e3f5062583 | ||
|
|
d427e0522e | ||
|
|
46e144f647 | ||
|
|
6ad20c53d9 | ||
|
|
cc433388f5 | ||
|
|
1fba4b7e32 | ||
|
|
c249cd4ffd | ||
|
|
d54bf5f75e | ||
|
|
9cbe3ff551 | ||
|
|
b1d20d3228 | ||
|
|
e5602abee0 | ||
|
|
a296a9e109 | ||
|
|
e5bbf47ab9 | ||
|
|
9bda2a44ae | ||
|
|
a1d32c3a21 | ||
|
|
b4b68c0b00 | ||
|
|
608bf30bda | ||
|
|
d0be5f09a5 | ||
|
|
3d8a3a2204 | ||
|
|
b19734004a | ||
|
|
f503c72782 | ||
|
|
f8903a8183 | ||
|
|
d43d5a6cb6 | ||
|
|
99f4b2cf95 | ||
|
|
b493d739bd | ||
|
|
92fb43a455 | ||
|
|
7c29fb449e | ||
|
|
7ddcec3a0d | ||
|
|
18f206747c | ||
|
|
0bce1da4e3 | ||
|
|
65ed047084 | ||
|
|
b0573f88e6 | ||
|
|
348fca7e28 | ||
|
|
cc9b750eff | ||
|
|
701ad8a4a9 | ||
|
|
2cc088c633 | ||
|
|
4dfba3a2ad | ||
|
|
a3721a94ce | ||
|
|
7439faad34 | ||
|
|
6efe54aa88 | ||
|
|
9e62d1ad8f | ||
|
|
e69251b21a | ||
|
|
f096c4a5d0 | ||
|
|
03b3f5937f | ||
|
|
696b5c80a7 | ||
|
|
d6fcd22752 | ||
|
|
54d9ca8ed8 | ||
|
|
05d8a06cd5 | ||
|
|
4f7626828a | ||
|
|
81193eb550 | ||
|
|
38b09dc610 | ||
|
|
ae3527df16 | ||
|
|
85d0270af0 | ||
|
|
59e185b781 | ||
|
|
b5ad29e3f2 | ||
|
|
d42b0691b2 | ||
|
|
3d286fbdc3 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
||||
github: [jeremystretch]
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report a reproducible bug in the current release of NetBox
|
||||
description: Report a reproducible bug in the current release of NetBox
|
||||
labels: ["type: bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: 📖 Documentation Change
|
||||
about: Suggest an addition or modification to the NetBox documentation
|
||||
description: Suggest an addition or modification to the NetBox documentation
|
||||
labels: ["type: documentation"]
|
||||
body:
|
||||
- type: dropdown
|
||||
@@ -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.
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
7
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Propose a new NetBox feature or enhancement
|
||||
description: Propose a new NetBox feature or enhancement
|
||||
labels: ["type: feature"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -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.
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
7
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: 🏡 Housekeeping
|
||||
about: A change pertaining to the codebase itself (developers only)
|
||||
description: A change pertaining to the codebase itself (developers only)
|
||||
labels: ["type: housekeeping"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -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
30
.github/stale.yml
vendored
@@ -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
34
.github/workflows/stale.yml
vendored
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
|
||||
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
|
||||
|
||||
```no-highlight
|
||||
sudo echo napalm >> /opt/netbox/local_requirements.txt
|
||||
sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
### Remote File Storage
|
||||
@@ -206,7 +206,7 @@ sudo echo napalm >> /opt/netbox/local_requirements.txt
|
||||
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
sudo echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
## Run the Upgrade Script
|
||||
|
||||
@@ -30,7 +30,7 @@ pip3 install django-auth-ldap
|
||||
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
|
||||
|
||||
```no-highlight
|
||||
sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt
|
||||
sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
# NetBox v2.11
|
||||
|
||||
## v2.11-beta1 (2021-04-07)
|
||||
## v2.11.2 (2021-04-27)
|
||||
|
||||
**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.
|
||||
### Enhancements
|
||||
|
||||
* [#6275](https://github.com/netbox-community/netbox/issues/6275) - Linkify rack, device counts on locations list
|
||||
* [#6278](https://github.com/netbox-community/netbox/issues/6278) - Note device locations on cable traces
|
||||
* [#6287](https://github.com/netbox-community/netbox/issues/6287) - Add option to clear assigned max length filter on prefixes list
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6236](https://github.com/netbox-community/netbox/issues/6236) - Journal entry title should account for configured timezone
|
||||
* [#6246](https://github.com/netbox-community/netbox/issues/6246) - Permit full-length descriptions when creating device components and VM interfaces
|
||||
* [#6248](https://github.com/netbox-community/netbox/issues/6248) - Fix table column reconfiguration under Chrome
|
||||
* [#6252](https://github.com/netbox-community/netbox/issues/6252) - Fix assignment of console port speed values above 19.2kbps
|
||||
* [#6254](https://github.com/netbox-community/netbox/issues/6254) - Disable ordering of space column in racks table
|
||||
* [#6258](https://github.com/netbox-community/netbox/issues/6258) - Fix parent assignment for SiteGroup API serializer
|
||||
* [#6262](https://github.com/netbox-community/netbox/issues/6262) - Support filtering by created/updated time for all relevant objects
|
||||
* [#6267](https://github.com/netbox-community/netbox/issues/6267) - Fix cable tracing API endpoint for circuit terminations
|
||||
* [#6289](https://github.com/netbox-community/netbox/issues/6289) - Fix assignment of VC member interfaces to LAG interfaces
|
||||
|
||||
---
|
||||
|
||||
## v2.11.1 (2021-04-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6161](https://github.com/netbox-community/netbox/issues/6161) - Enable ordering of device component tables
|
||||
* [#6179](https://github.com/netbox-community/netbox/issues/6179) - Enable natural ordering for virtual machines
|
||||
* [#6189](https://github.com/netbox-community/netbox/issues/6189) - Add ability to search for locations by name or description
|
||||
* [#6190](https://github.com/netbox-community/netbox/issues/6190) - Allow filtering devices with no location assigned
|
||||
* [#6210](https://github.com/netbox-community/netbox/issues/6210) - Include child locations on location view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6184](https://github.com/netbox-community/netbox/issues/6184) - Fix parent object table column in prefix IP addresses list
|
||||
* [#6188](https://github.com/netbox-community/netbox/issues/6188) - Support custom field filtering for regions, site groups, and locations
|
||||
* [#6196](https://github.com/netbox-community/netbox/issues/6196) - Fix object list display for users with read-only permissions
|
||||
* [#6215](https://github.com/netbox-community/netbox/issues/6215) - Restore tenancy section in virtual machine form
|
||||
|
||||
---
|
||||
|
||||
## 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 +62,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 +132,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 +144,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 +183,52 @@ 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
|
||||
* The `trace/` endpoint has been replaced with `paths/`
|
||||
* 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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -2,7 +2,7 @@ from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits import filters
|
||||
from circuits.models import *
|
||||
from dcim.api.views import PathEndpointMixin
|
||||
from dcim.api.views import PassThroughPortMixin
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.utils import count_related
|
||||
@@ -57,7 +57,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = CircuitTermination.objects.prefetch_related(
|
||||
'circuit', 'site', 'provider_network', 'cable'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
|
||||
from dcim.filters import CableTerminationFilterSet
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
@@ -110,7 +110,7 @@ class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, Created
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
@@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
|
||||
class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableTerminationFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -90,7 +90,7 @@ class RegionSerializer(NestedGroupModelSerializer):
|
||||
|
||||
class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
|
||||
parent = NestedRegionSerializer(required=False, allow_null=True)
|
||||
parent = NestedSiteGroupSerializer(required=False, allow_null=True)
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -57,7 +57,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Parent region (ID)',
|
||||
@@ -74,7 +74,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Parent site group (ID)',
|
||||
@@ -154,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
@@ -191,23 +191,34 @@ 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:
|
||||
model = Location
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
|
||||
class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
@@ -312,7 +323,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
)
|
||||
|
||||
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet):
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -372,7 +383,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
@@ -465,7 +476,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat
|
||||
return queryset.exclude(devicebaytemplates__isnull=value)
|
||||
|
||||
|
||||
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
|
||||
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
devicetype_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
field_name='device_type_id',
|
||||
@@ -495,6 +506,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 +517,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@@ -509,6 +528,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
@@ -516,6 +539,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
@@ -529,14 +556,14 @@ class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
fields = ['id', 'name']
|
||||
|
||||
|
||||
class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role']
|
||||
|
||||
|
||||
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -765,7 +792,7 @@ class DeviceFilterSet(
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class DeviceComponentFilterSet(CustomFieldModelFilterSet):
|
||||
class DeviceComponentFilterSet(CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -892,6 +919,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
|
||||
@@ -953,7 +984,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
|
||||
vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
@@ -964,7 +995,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
try:
|
||||
devices = Device.objects.filter(pk__in=id_list)
|
||||
for device in devices:
|
||||
vc_interface_ids += device.vc_interfaces.values_list('id', flat=True)
|
||||
vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
@@ -997,6 +1028,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 +1039,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 +1129,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class VirtualChassisFilterSet(BaseFilterSet):
|
||||
class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1170,7 +1209,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class CableFilterSet(BaseFilterSet):
|
||||
class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1301,7 +1340,7 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
fields = []
|
||||
|
||||
|
||||
class PowerPanelFilterSet(BaseFilterSet):
|
||||
class PowerPanelFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1346,7 +1385,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 +1459,10 @@ class PowerFeedFilterSet(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerFeedStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -230,7 +230,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['parent', 'description']
|
||||
|
||||
|
||||
class RegionFilterForm(BootstrapMixin, forms.Form):
|
||||
class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -287,8 +287,8 @@ class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['parent', 'description']
|
||||
|
||||
|
||||
class SiteGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
model = Site
|
||||
class SiteGroupFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = SiteGroup
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
@@ -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.',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -557,7 +557,12 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['parent', 'description']
|
||||
|
||||
|
||||
class LocationFilterForm(BootstrapMixin, forms.Form):
|
||||
class LocationFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Location
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -2056,7 +2061,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': 'location',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
position = forms.IntegerField(
|
||||
@@ -2148,7 +2153,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
|
||||
interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True)
|
||||
interface_ids = self.instance.vc_interfaces().values_list('pk', flat=True)
|
||||
|
||||
# Collect interface IPs
|
||||
interface_ips = IPAddress.objects.filter(
|
||||
@@ -2424,10 +2429,11 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
}
|
||||
},
|
||||
label=_('Location')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -2546,7 +2552,7 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm):
|
||||
queryset=Device.objects.all()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
@@ -3072,10 +3078,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(),
|
||||
|
||||
21
netbox/dcim/migrations/0131_consoleport_speed.py
Normal file
21
netbox/dcim/migrations/0131_consoleport_speed.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0130_sitegroup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='speed',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverport',
|
||||
name='speed',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -222,7 +222,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
speed = models.PositiveSmallIntegerField(
|
||||
speed = models.PositiveIntegerField(
|
||||
choices=ConsolePortSpeedChoices,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -265,7 +265,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
speed = models.PositiveSmallIntegerField(
|
||||
speed = models.PositiveIntegerField(
|
||||
choices=ConsolePortSpeedChoices,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -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
|
||||
|
||||
@@ -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}.",
|
||||
@@ -712,7 +716,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
pass
|
||||
|
||||
# Validate primary IP addresses
|
||||
vc_interfaces = self.vc_interfaces.all()
|
||||
vc_interfaces = self.vc_interfaces()
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.family != 4:
|
||||
raise ValidationError({
|
||||
@@ -850,20 +854,27 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def interfaces_count(self):
|
||||
if self.virtual_chassis and self.virtual_chassis.master == self:
|
||||
return self.vc_interfaces().count()
|
||||
return self.interfaces.count()
|
||||
|
||||
def get_vc_master(self):
|
||||
"""
|
||||
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||
"""
|
||||
return self.virtual_chassis.master if self.virtual_chassis else None
|
||||
|
||||
@property
|
||||
def vc_interfaces(self):
|
||||
def vc_interfaces(self, if_master=False):
|
||||
"""
|
||||
Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
|
||||
Device belonging to the same VirtualChassis.
|
||||
|
||||
:param if_master: If True, return VC member interfaces only if this Device is the VC master.
|
||||
"""
|
||||
filter = Q(device=self)
|
||||
if self.virtual_chassis and self.virtual_chassis.master == self:
|
||||
if self.virtual_chassis and (not if_master or self.virtual_chassis.master == self):
|
||||
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
|
||||
return Interface.objects.filter(filter)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -291,6 +291,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class DeviceConsolePortTable(ConsolePortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -335,6 +336,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-console-network-outline"></i> '
|
||||
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -379,6 +381,7 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
|
||||
'{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -428,6 +431,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
class DevicePowerOutletTable(PowerOutletTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -492,6 +496,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
|
||||
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
|
||||
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
parent = tables.Column(
|
||||
@@ -555,6 +560,7 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
|
||||
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -602,6 +608,7 @@ class DeviceRearPortTable(RearPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
|
||||
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -651,6 +658,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}'
|
||||
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
@@ -698,6 +706,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
|
||||
'{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
|
||||
@@ -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):
|
||||
@@ -101,6 +73,7 @@ class RackDetailTable(RackTable):
|
||||
verbose_name='Devices'
|
||||
)
|
||||
get_utilization = UtilizationColumn(
|
||||
orderable=False,
|
||||
verbose_name='Space'
|
||||
)
|
||||
get_power_utilization = UtilizationColumn(
|
||||
@@ -113,11 +86,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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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,36 @@ 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 = LinkedCountColumn(
|
||||
viewname='dcim:rack_list',
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Racks'
|
||||
)
|
||||
device_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'location_id': 'pk'},
|
||||
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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
@@ -358,16 +364,30 @@ class LocationView(generic.ObjectView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
location=instance
|
||||
)
|
||||
location_ids = instance.get_descendants(include_self=True).values_list('pk', flat=True)
|
||||
rack_count = Rack.objects.filter(location__in=location_ids).count()
|
||||
device_count = Device.objects.filter(location__in=location_ids).count()
|
||||
|
||||
devices_table = tables.DeviceTable(devices)
|
||||
devices_table.columns.hide('location')
|
||||
paginate_table(devices_table, request)
|
||||
child_locations = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).filter(pk__in=location_ids).exclude(pk=instance.pk)
|
||||
child_locations_table = tables.LocationTable(child_locations)
|
||||
paginate_table(child_locations_table, request)
|
||||
|
||||
return {
|
||||
'devices_table': devices_table,
|
||||
'rack_count': rack_count,
|
||||
'device_count': device_count,
|
||||
'child_locations_table': child_locations_table,
|
||||
}
|
||||
|
||||
|
||||
@@ -1166,14 +1186,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
|
||||
|
||||
|
||||
@@ -1293,8 +1319,7 @@ class DeviceConsolePortsView(generic.ObjectView):
|
||||
)
|
||||
consoleport_table = tables.DeviceConsolePortTable(
|
||||
data=consoleports,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'):
|
||||
consoleport_table.columns.show('pk')
|
||||
@@ -1318,8 +1343,7 @@ class DeviceConsoleServerPortsView(generic.ObjectView):
|
||||
)
|
||||
consoleserverport_table = tables.DeviceConsoleServerPortTable(
|
||||
data=consoleserverports,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_consoleserverport') or \
|
||||
request.user.has_perm('dcim.delete_consoleserverport'):
|
||||
@@ -1342,8 +1366,7 @@ class DevicePowerPortsView(generic.ObjectView):
|
||||
)
|
||||
powerport_table = tables.DevicePowerPortTable(
|
||||
data=powerports,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'):
|
||||
powerport_table.columns.show('pk')
|
||||
@@ -1365,8 +1388,7 @@ class DevicePowerOutletsView(generic.ObjectView):
|
||||
)
|
||||
poweroutlet_table = tables.DevicePowerOutletTable(
|
||||
data=poweroutlets,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'):
|
||||
poweroutlet_table.columns.show('pk')
|
||||
@@ -1383,15 +1405,14 @@ class DeviceInterfacesView(generic.ObjectView):
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
|
||||
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
|
||||
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
|
||||
'lag', 'cable', '_path__destination', 'tags',
|
||||
)
|
||||
interface_table = tables.DeviceInterfaceTable(
|
||||
data=interfaces,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'):
|
||||
interface_table.columns.show('pk')
|
||||
@@ -1413,8 +1434,7 @@ class DeviceFrontPortsView(generic.ObjectView):
|
||||
)
|
||||
frontport_table = tables.DeviceFrontPortTable(
|
||||
data=frontports,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'):
|
||||
frontport_table.columns.show('pk')
|
||||
@@ -1434,8 +1454,7 @@ class DeviceRearPortsView(generic.ObjectView):
|
||||
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable')
|
||||
rearport_table = tables.DeviceRearPortTable(
|
||||
data=rearports,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'):
|
||||
rearport_table.columns.show('pk')
|
||||
@@ -1457,8 +1476,7 @@ class DeviceDeviceBaysView(generic.ObjectView):
|
||||
)
|
||||
devicebay_table = tables.DeviceDeviceBayTable(
|
||||
data=devicebays,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'):
|
||||
devicebay_table.columns.show('pk')
|
||||
@@ -1480,8 +1498,7 @@ class DeviceInventoryView(generic.ObjectView):
|
||||
).prefetch_related('manufacturer')
|
||||
inventoryitem_table = tables.DeviceInventoryItemTable(
|
||||
data=inventoryitems,
|
||||
user=request.user,
|
||||
orderable=False
|
||||
user=request.user
|
||||
)
|
||||
if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'):
|
||||
inventoryitem_table.columns.show('pk')
|
||||
@@ -1510,7 +1527,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
|
||||
template_name = 'dcim/device/lldp_neighbors.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
|
||||
'_path__destination'
|
||||
).exclude(
|
||||
type__in=NONCONNECTABLE_IFACE_TYPES
|
||||
@@ -2830,6 +2847,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 +2925,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -36,6 +36,27 @@ EXACT_FILTER_TYPES = (
|
||||
)
|
||||
|
||||
|
||||
class CreatedUpdatedFilterSet(django_filters.FilterSet):
|
||||
created = django_filters.DateFilter()
|
||||
created__gte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created__lte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
last_updated = django_filters.DateTimeFilter()
|
||||
last_updated__gte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
last_updated__lte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
|
||||
class WebhookFilterSet(BaseFilterSet):
|
||||
content_types = ContentTypeFilter()
|
||||
http_method = django_filters.MultipleChoiceFilter(
|
||||
@@ -90,6 +111,7 @@ class CustomFieldModelFilterSet(django_filters.FilterSet):
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
content_types = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
@@ -118,7 +140,7 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
fields = ['id', 'content_type_id', 'object_id', 'name']
|
||||
|
||||
|
||||
class JournalEntryFilterSet(BaseFilterSet):
|
||||
class JournalEntryFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -149,7 +171,7 @@ class JournalEntryFilterSet(BaseFilterSet):
|
||||
return queryset.filter(comments__icontains=value)
|
||||
|
||||
|
||||
class TagFilterSet(BaseFilterSet):
|
||||
class TagFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -168,7 +190,7 @@ class TagFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterSet(BaseFilterSet):
|
||||
class ConfigContextFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -340,27 +362,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class CreatedUpdatedFilterSet(django_filters.FilterSet):
|
||||
created = django_filters.DateFilter()
|
||||
created__gte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created__lte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
last_updated = django_filters.DateTimeFilter()
|
||||
last_updated__gte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
last_updated__lte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Job Results
|
||||
#
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 @@ 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()}"
|
||||
created_date = timezone.localdate(self.created)
|
||||
created_time = timezone.localtime(self.created)
|
||||
return f"{date_format(created_date)} - {time_format(created_time)} ({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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
||||
class IPAMConfig(AppConfig):
|
||||
name = "ipam"
|
||||
verbose_name = "IPAM"
|
||||
|
||||
def ready(self):
|
||||
import ipam.signals
|
||||
|
||||
@@ -116,7 +116,7 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilt
|
||||
fields = ['id', 'name']
|
||||
|
||||
|
||||
class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
@@ -173,7 +173,7 @@ class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -515,7 +515,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
|
||||
return queryset.none()
|
||||
interface_ids = []
|
||||
for device in devices:
|
||||
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
|
||||
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return queryset.filter(
|
||||
interface__in=interface_ids
|
||||
)
|
||||
@@ -535,7 +535,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
|
||||
return queryset.exclude(assigned_object_id__isnull=value)
|
||||
|
||||
|
||||
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
scope_type = ContentTypeFilter()
|
||||
region = django_filters.NumberFilter(
|
||||
method='filter_scope'
|
||||
|
||||
@@ -1561,7 +1561,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||
if self.instance.device:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
|
||||
interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
|
||||
)
|
||||
elif self.instance.virtual_machine:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
|
||||
21
netbox/ipam/signals.py
Normal file
21
netbox/ipam/signals.py
Normal 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()
|
||||
@@ -340,10 +340,10 @@ class IPAddressTable(BaseTable):
|
||||
verbose_name='Interface'
|
||||
)
|
||||
assigned_object_parent = tables.Column(
|
||||
accessor='assigned_object__parent',
|
||||
accessor='assigned_object.parent_object',
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Interface Parent'
|
||||
verbose_name='Device/VM'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.11-beta1'
|
||||
VERSION = '2.11.2'
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -362,6 +362,9 @@ table.report th a {
|
||||
border-radius: .25em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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').prop("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();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = SecretRole
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<li role="presentation" {% if active_tab == 'device' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
|
||||
</li>
|
||||
{% with interface_count=object.vc_interfaces.count %}
|
||||
{% with interface_count=object.interfaces_count %}
|
||||
{% if interface_count %}
|
||||
<li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
|
||||
|
||||
@@ -40,10 +40,16 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Racks</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Devices</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
|
||||
<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}">{{ device_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -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>
|
||||
@@ -59,18 +79,18 @@
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Devices</strong>
|
||||
<strong>Locations</strong>
|
||||
</div>
|
||||
{% include 'inc/table.html' with table=devices_table %}
|
||||
{% if perms.dcim.add_device %}
|
||||
{% include 'inc/table.html' with table=child_locations_table %}
|
||||
{% if perms.dcim.add_location %}
|
||||
<div class="panel-footer text-right noprint">
|
||||
<a href="{% url 'dcim:device_add' %}?location={{ object.pk }}" class="btn btn-xs btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device
|
||||
<a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}" class="btn btn-xs btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add location
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
|
||||
{% include 'inc/paginator.html' with paginator=child_locations_table.paginator page=child_locations_table.page %}
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<strong><a href="{{ device.get_absolute_url }}">{{ device }}</a></strong><br />
|
||||
{{ device.device_type.manufacturer }} {{ device.device_type }}<br />
|
||||
<a href="{{ device.site.get_absolute_url }}">{{ device.site }}</a>
|
||||
{% if device.location %}
|
||||
/ <a href="{{ device.location.get_absolute_url }}">{{ device.location }}</a>
|
||||
{% endif %}
|
||||
{% if device.rack %}
|
||||
/ <a href="{{ device.rack.get_absolute_url }}">{{ device.rack }}</a>
|
||||
{% endif %}
|
||||
|
||||
57
netbox/templates/extras/journalentry.html
Normal file
57
netbox/templates/extras/journalentry.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -66,9 +66,11 @@
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ message }}</td>
|
||||
<td class="rendered-markdown">{{ message|render_markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }} ·
|
||||
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 %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
@@ -29,52 +30,56 @@
|
||||
</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 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 %}
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% include table_template|default:'responsive_table.html' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<p>
|
||||
<small class="text-muted">Created {{ object.created }} · Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
|
||||
</p>
|
||||
@@ -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>
|
||||
@@ -9,6 +9,11 @@
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="max_length">
|
||||
{% if request.GET.mask_length__lte %}
|
||||
<li>
|
||||
<a href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=None page=1 %}">Clear</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
|
||||
<li><a href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=i page=1 %}">
|
||||
{{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
|
||||
@@ -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 }} · 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -13,7 +13,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
label='Tenant group (ID)',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .api import *
|
||||
from .base import *
|
||||
from .utils import *
|
||||
from .views import *
|
||||
|
||||
@@ -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.
|
||||
|
||||
161
netbox/utilities/testing/base.py
Normal file
161
netbox/utilities/testing/base.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user