mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-01 22:53:39 +01:00
Compare commits
32 Commits
v2.10-beta
...
v2.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5aa69321 | ||
|
|
848cfeb353 | ||
|
|
7b05a18173 | ||
|
|
2ce99929e2 | ||
|
|
f1e82a3647 | ||
|
|
1c5af01a82 | ||
|
|
bac3ace8fc | ||
|
|
68599351aa | ||
|
|
86755029ef | ||
|
|
c507ab30e9 | ||
|
|
7d1614b933 | ||
|
|
a77d1e502c | ||
|
|
d79ed76d80 | ||
|
|
ccf8059452 | ||
|
|
3d3d1bc623 | ||
|
|
e66d065b6d | ||
|
|
c1ef87e009 | ||
|
|
3c249a40a0 | ||
|
|
5092641157 | ||
|
|
472a45ddec | ||
|
|
120cbb0159 | ||
|
|
68fbd9b017 | ||
|
|
3143f75a38 | ||
|
|
e13d4ffe60 | ||
|
|
295d4f0394 | ||
|
|
ea91e09a1b | ||
|
|
946779000f | ||
|
|
5c07b6dc1d | ||
|
|
25c3c1b431 | ||
|
|
a0ae7a227d | ||
|
|
50df3acd26 | ||
|
|
425670f52a |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,5 +5,5 @@ contact_links:
|
||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||
about: Please read through our contributing policy before opening an issue or pull request
|
||||
- name: 💬 Discussion Group
|
||||
url: https://groups.google.com/g/netbox-discuss
|
||||
url: https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
about: Join our discussion group for assistance with installation issues and other problems
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
|
||||
If you have a general idea or question, please post to our mailing list
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
|
||||
NOTE: Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which significantly extend NetBox's feature scope.
|
||||
@@ -21,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
|
||||
before submitting a bug report.
|
||||
-->
|
||||
### Environment
|
||||
* Python version:
|
||||
* NetBox version:
|
||||
* Python version: <!-- Example: 3.6.9 -->
|
||||
* NetBox version: <!-- Example: 2.7.3 -->
|
||||
|
||||
<!--
|
||||
Describe in detail the new functionality you are proposing. Include any
|
||||
|
||||
23
.github/lock.yml
vendored
Normal file
23
.github/lock.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Configuration for Lock (https://github.com/apps/lock)
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 90
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: false
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels: []
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: false
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: true
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
21
.github/workflows/lock.yml
vendored
21
.github/workflows/lock.yml
vendored
@@ -1,21 +0,0 @@
|
||||
# lock-threads (https://github.com/marketplace/actions/lock-threads)
|
||||
name: 'Lock threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: '90'
|
||||
issue-exclude-created-before: ''
|
||||
issue-exclude-labels: ''
|
||||
issue-lock-labels: ''
|
||||
issue-lock-comment: ''
|
||||
issue-lock-reason: 'resolved'
|
||||
process-only: 'issues'
|
||||
@@ -8,7 +8,6 @@ language: python
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install pycodestyle
|
||||
|
||||
@@ -8,7 +8,7 @@ except to report bugs or request features.
|
||||
|
||||
We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
|
||||
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
|
||||
### Slack
|
||||
|
||||
@@ -103,18 +103,14 @@ any work that's already in progress.
|
||||
be assigned to you so that others are aware it's being worked on. A maintainer
|
||||
will then mark the issue as "accepted."
|
||||
|
||||
* Any pull request which does _not_ relate to an **accepted** issue will be closed.
|
||||
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
||||
|
||||
* All new functionality must include relevant tests where applicable.
|
||||
* All major new functionality must include relevant tests where applicable.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of the `develop`
|
||||
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||
development, while `master` is used for tagging stable releases.
|
||||
|
||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will
|
||||
take care of this when the PR is merged. (This helps avoid merge conflicts
|
||||
resulting from multiple PRs being submitted simultaneously.)
|
||||
|
||||
* All code submissions should meet the following criteria (CI will enforce
|
||||
these checks):
|
||||
|
||||
@@ -168,7 +164,7 @@ overlooked.
|
||||
* Official channels for communication include:
|
||||
|
||||
* GitHub issues/pull requests
|
||||
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
|
||||
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
|
||||
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
|
||||
|
||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
||||
|
||||
@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
|
||||
If you are interested in contributing to the development of NetBox, please read
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
@@ -1,69 +1,26 @@
|
||||
# Custom Fields
|
||||
|
||||
Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
|
||||
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
|
||||
|
||||
However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data.
|
||||
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data.
|
||||
|
||||
Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects.
|
||||
Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types:
|
||||
|
||||
## Creating Custom Fields
|
||||
* Free-form text (up to 255 characters)
|
||||
* Integer
|
||||
* Boolean (true/false)
|
||||
* Date
|
||||
* URL
|
||||
* Selection
|
||||
|
||||
Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field:
|
||||
Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form.
|
||||
|
||||
* Text: Free-form text (up to 255 characters)
|
||||
* Integer: A whole number (positive or negative)
|
||||
* Boolean: True or false
|
||||
* Date: A date in ISO 8601 format (YYYY-MM-DD)
|
||||
* URL: This will be presented as a link in the web UI
|
||||
* Selection: A selection of one of several pre-defined custom choices
|
||||
Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.)
|
||||
|
||||
Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
|
||||
When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically.
|
||||
|
||||
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
|
||||
## Using Custom Fields
|
||||
|
||||
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
|
||||
When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object.
|
||||
|
||||
A custom field must be assigned to one or object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
|
||||
|
||||
### Custom Field Validation
|
||||
|
||||
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:
|
||||
|
||||
* Text: Regular expression (optional)
|
||||
* Integer: Minimum and/or maximum value (optional)
|
||||
* Selection: Must exactly match one of the prescribed choices
|
||||
|
||||
### Custom Selection Fields
|
||||
|
||||
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
|
||||
|
||||
If a default value is specified for a selection field, it must exactly match one of the provided choices.
|
||||
|
||||
## Custom Fields and the REST API
|
||||
|
||||
When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"url": "http://localhost:8000/api/dcim/sites/123/",
|
||||
"name": "Raleigh 42",
|
||||
...
|
||||
"custom_fields": {
|
||||
"deployed": "2018-06-19",
|
||||
"site_code": "US-NC-RAL42"
|
||||
},
|
||||
...
|
||||
```
|
||||
|
||||
To set or change these values, simply include nested JSON data. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "New Site",
|
||||
"slug": "new-site",
|
||||
"custom_fields": {
|
||||
"deployed": "2019-03-24"
|
||||
}
|
||||
}
|
||||
```
|
||||
When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field.
|
||||
|
||||
@@ -17,18 +17,6 @@ When viewing a device named Router4, this link would render as:
|
||||
|
||||
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `obj` | The NetBox object being displayed |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
|
||||
|
||||
@@ -231,30 +231,6 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
* `min_prefix_length` - Minimum length of the mask
|
||||
* `max_prefix_length` - Maximum length of the mask
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
|
||||
|
||||
### Via the API
|
||||
|
||||
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
http://netbox/api/extras/scripts/example.MyReport/ \
|
||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
|
||||
|
||||
30
docs/additional-features/graphs.md
Normal file
30
docs/additional-features/graphs.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Graphs
|
||||
|
||||
!!! warning
|
||||
Native support for embedded graphs is due to be removed in NetBox v2.10. It will likely be superseded by a plugin providing similar functionality.
|
||||
|
||||
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
|
||||
|
||||
* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
|
||||
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
|
||||
* **Name:** The title to display above the graph.
|
||||
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
|
||||
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
|
||||
|
||||
Graph names and links can be rendered using Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/).
|
||||
|
||||
## Examples
|
||||
|
||||
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
|
||||
|
||||
```
|
||||
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
|
||||
```
|
||||
|
||||
You can define several graphs to provide multiple contexts when viewing an object. For example:
|
||||
|
||||
```
|
||||
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
|
||||
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
|
||||
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
|
||||
```
|
||||
@@ -101,14 +101,11 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
|
||||
|
||||
Once a report has been run, its associated results will be included in the report view.
|
||||
|
||||
### Via the API
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
|
||||
>>> vlan
|
||||
<VLAN: 123 (BetterName)>
|
||||
>>> vlan.delete()
|
||||
(1, {'ipam.VLAN': 1})
|
||||
(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1})
|
||||
```
|
||||
|
||||
To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
|
||||
@@ -194,9 +194,9 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
|
||||
>>> Device.objects.filter(name__icontains='test').count()
|
||||
27
|
||||
>>> Device.objects.filter(name__icontains='test').delete()
|
||||
(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
|
||||
'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
|
||||
'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
||||
(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0,
|
||||
'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27,
|
||||
'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -73,9 +73,8 @@ tar -xf netbox_media.tar.gz
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
|
||||
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command:
|
||||
|
||||
```no-highlight
|
||||
# source /opt/netbox/venv/bin/activate
|
||||
(venv) # python3 manage.py invalidate all
|
||||
python3 manage.py invalidate all
|
||||
```
|
||||
|
||||
@@ -491,14 +491,6 @@ The file path to the location where custom reports will be kept. By default, thi
|
||||
|
||||
---
|
||||
|
||||
## RQ_DEFAULT_TIMEOUT
|
||||
|
||||
Default: `300`
|
||||
|
||||
The maximum execution time of a background task (such as running a custom script), in seconds.
|
||||
|
||||
---
|
||||
|
||||
## SCRIPTS_ROOT
|
||||
|
||||
Default: `$INSTALL_ROOT/netbox/scripts/`
|
||||
|
||||
@@ -65,6 +65,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
|
||||
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
|
||||
* `PASSWORD` - Redis password (if set)
|
||||
* `DATABASE` - Numeric database ID
|
||||
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
|
||||
* `SSL` - Use SSL connection to Redis
|
||||
|
||||
An example configuration is provided below:
|
||||
@@ -76,6 +77,7 @@ REDIS = {
|
||||
'PORT': 1234,
|
||||
'PASSWORD': 'foobar',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -83,6 +85,7 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@@ -106,7 +109,6 @@ above and the addition of two new keys.
|
||||
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
|
||||
of the Redis server and port for each sentinel instance to connect to
|
||||
* `SENTINEL_SERVICE`: Name of the master / service to connect to
|
||||
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
|
||||
|
||||
Example:
|
||||
|
||||
@@ -115,9 +117,9 @@ REDIS = {
|
||||
'tasks': {
|
||||
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'SENTINEL_TIMEOUT': 10,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -128,6 +130,7 @@ REDIS = {
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
---
|
||||
|
||||
{!docs/models/ipam/vrf.md!}
|
||||
{!docs/models/ipam/routetarget.md!}
|
||||
|
||||
@@ -7,7 +7,7 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
Communication among developers should always occur via public channels:
|
||||
|
||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
|
||||
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
|
||||
## Governance
|
||||
|
||||
@@ -89,3 +89,7 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
|
||||
```
|
||||
VERSION = 'v2.3.5-dev'
|
||||
```
|
||||
|
||||
### Announce the Release
|
||||
|
||||
Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.
|
||||
|
||||
@@ -55,7 +55,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
|
||||
## Supported Python Versions
|
||||
|
||||
NetBox supports Python 3.6, 3.7, and 3.8 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.)
|
||||
NetBox supports Python 3.6 and 3.7 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.)
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
@@ -5,27 +5,30 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
!!! warning
|
||||
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
## Installation
|
||||
|
||||
#### Ubuntu
|
||||
|
||||
Install the PostgreSQL server and client development libraries using `apt`.
|
||||
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
|
||||
|
||||
```no-highlight
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql libpq-dev
|
||||
# apt-get update
|
||||
# apt-get install -y postgresql libpq-dev
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
|
||||
CentOS 7 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y postgresql-server libpq-devel
|
||||
sudo postgresql-setup --initdb
|
||||
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
|
||||
# yum install -y postgresql96 postgresql96-server postgresql96-devel
|
||||
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
|
||||
```
|
||||
|
||||
CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
|
||||
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/9.6/data/pg_hba.conf`. For example:
|
||||
|
||||
```no-highlight
|
||||
host all all 127.0.0.1/32 md5
|
||||
@@ -35,8 +38,8 @@ host all all ::1/128 md5
|
||||
Then, start the service and enable it to run at boot:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start postgresql
|
||||
sudo systemctl enable postgresql
|
||||
# systemctl start postgresql-9.6
|
||||
# systemctl enable postgresql-9.6
|
||||
```
|
||||
|
||||
## Database Creation
|
||||
@@ -47,8 +50,8 @@ At a minimum, we need to create a database for NetBox and assign it a username a
|
||||
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
|
||||
|
||||
```no-highlight
|
||||
$ sudo -u postgres psql
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
# sudo -u postgres psql
|
||||
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
@@ -65,16 +68,13 @@ postgres=# \q
|
||||
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
|
||||
|
||||
```no-highlight
|
||||
$ psql --username netbox --password --host localhost netbox
|
||||
# psql --username netbox --password --host localhost netbox
|
||||
Password for user netbox:
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
netbox=> \conninfo
|
||||
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
netbox=> \q
|
||||
```
|
||||
|
||||
If successful, you will enter a `netbox` prompt. Type `\conninfo` to confirm your connection, or type `\q` to exit.
|
||||
If successful, you will enter a `netbox` prompt. Type `\q` to exit.
|
||||
|
||||
@@ -10,15 +10,16 @@
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y redis-server
|
||||
# apt-get install -y redis-server
|
||||
```
|
||||
|
||||
### CentOS
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y redis
|
||||
sudo systemctl start redis
|
||||
sudo systemctl enable redis
|
||||
# yum install -y epel-release
|
||||
# yum install -y redis
|
||||
# systemctl start redis
|
||||
# systemctl enable redis
|
||||
```
|
||||
|
||||
You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
|
||||
|
||||
@@ -7,39 +7,40 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
!!! note
|
||||
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
|
||||
NetBox v2.8.0 and later require Python 3.6 or 3.7. This documentation assumes Python 3.6.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
### CentOS
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
# easy_install-3.6 pip
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
|
||||
```no-highlight
|
||||
sudo pip3 install --upgrade pip
|
||||
# pip install --upgrade pip
|
||||
```
|
||||
|
||||
## Download NetBox
|
||||
|
||||
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
|
||||
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and decompressing the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
|
||||
|
||||
### Option A: Download a Release Archive
|
||||
|
||||
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
|
||||
|
||||
```no-highlight
|
||||
$ sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
$ sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
$ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
$ ls -l /opt | grep netbox
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# ls -l /opt | grep netbox
|
||||
lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
|
||||
drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
|
||||
```
|
||||
@@ -52,7 +53,7 @@ drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
|
||||
```no-highlight
|
||||
sudo mkdir -p /opt/netbox/ && cd /opt/netbox/
|
||||
# mkdir -p /opt/netbox/ && cd /opt/netbox/
|
||||
```
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
@@ -60,19 +61,19 @@ If `git` is not already installed, install it:
|
||||
#### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y git
|
||||
# apt-get install -y git
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y git
|
||||
# yum install -y git
|
||||
```
|
||||
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
|
||||
|
||||
```no-highlight
|
||||
$ sudo git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
# git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
@@ -92,16 +93,16 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
|
||||
#### Ubuntu
|
||||
|
||||
```
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
# adduser --system --group netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
```
|
||||
sudo groupadd --system netbox
|
||||
sudo adduser --system -g netbox netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
# groupadd --system netbox
|
||||
# adduser --system -g netbox netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -109,8 +110,8 @@ sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters.
|
||||
|
||||
```no-highlight
|
||||
cd /opt/netbox/netbox/netbox/
|
||||
sudo cp configuration.example.py configuration.py
|
||||
# cd /opt/netbox/netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
|
||||
@@ -153,7 +154,7 @@ DATABASE = {
|
||||
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
|
||||
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique database ID.
|
||||
|
||||
```python
|
||||
REDIS = {
|
||||
@@ -162,6 +163,7 @@ REDIS = {
|
||||
'PORT': 6379, # Redis port
|
||||
'PASSWORD': '', # Redis password (optional)
|
||||
'DATABASE': 0, # Database ID
|
||||
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
|
||||
'SSL': False, # Use SSL (optional)
|
||||
},
|
||||
'caching': {
|
||||
@@ -169,6 +171,7 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1, # Unique ID for second database
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@@ -181,7 +184,7 @@ This parameter must be assigned a randomly-generated key employed as a salt for
|
||||
A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key:
|
||||
|
||||
```no-highlight
|
||||
python3 ../generate_secret_key.py
|
||||
# python3 ../generate_secret_key.py
|
||||
```
|
||||
|
||||
!!! warning
|
||||
@@ -198,7 +201,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
|
||||
# echo napalm >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
### Remote File Storage
|
||||
@@ -206,7 +209,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/#storage_backend) in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
sudo echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
# echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
## Run the Upgrade Script
|
||||
@@ -219,7 +222,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
```no-highlight
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
# /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
!!! note
|
||||
@@ -230,7 +233,7 @@ sudo /opt/netbox/upgrade.sh
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
|
||||
|
||||
```no-highlight
|
||||
source /opt/netbox/venv/bin/activate
|
||||
# source /opt/netbox/venv/bin/activate
|
||||
```
|
||||
|
||||
Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt.
|
||||
@@ -238,8 +241,8 @@ Once the virtual environment has been activated, you should notice the string `(
|
||||
Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox
|
||||
(venv) $ python3 manage.py createsuperuser
|
||||
(venv) # cd /opt/netbox/netbox
|
||||
(venv) # python3 manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
@@ -249,33 +252,28 @@ Superuser created successfully.
|
||||
|
||||
## Test the Application
|
||||
|
||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
|
||||
At this point, we should be able to run NetBox. We can check by starting a development instance:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
November 17, 2020 - 16:08:13
|
||||
Django version 3.1.3, using settings 'netbox.settings'
|
||||
November 28, 2018 - 09:33:45
|
||||
Django version 2.0.9, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page.
|
||||
|
||||
!!! warning
|
||||
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
|
||||
|
||||
!!! warning
|
||||
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
|
||||
|
||||
Note that the initial user interface will be locked down for non-authenticated users.
|
||||
Note that the initial UI will be locked down for non-authenticated users.
|
||||
|
||||

|
||||
|
||||
Try logging in using the superuser account we just created. Once authenticated, you'll be able to access all areas of the UI:
|
||||
Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI:
|
||||
|
||||

|
||||
|
||||
Type `Ctrl+c` to stop the development server.
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
# Gunicorn
|
||||
|
||||
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) (which is automatically installed with NetBox) for this role, however other WSGI servers are available and should work similarly well. [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) is a popular alternative.
|
||||
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) for this role, however other WSGIs are available and should work similarly well.
|
||||
|
||||
## Configuration
|
||||
|
||||
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any local changes to it do not get overwritten by a future upgrade.)
|
||||
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any changes to it do not get overwritten by a future upgrade.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py
|
||||
# cd /opt/netbox
|
||||
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
|
||||
```
|
||||
|
||||
While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
|
||||
While this default configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
|
||||
|
||||
## systemd Setup
|
||||
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
|
||||
|
||||
```no-highlight
|
||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
# cp contrib/*.service /etc/systemd/system/
|
||||
# systemctl daemon-reload
|
||||
```
|
||||
|
||||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl start netbox netbox-rq
|
||||
sudo systemctl enable netbox netbox-rq
|
||||
# systemctl start netbox netbox-rq
|
||||
# systemctl enable netbox netbox-rq
|
||||
```
|
||||
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
@@ -33,20 +34,16 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
|
||||
```no-highlight
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Tue 2020-11-17 16:18:23 UTC; 3min 35s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 22836 (gunicorn)
|
||||
Tasks: 6 (limit: 2345)
|
||||
Memory: 339.3M
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─22836 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─22854 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─22855 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
...
|
||||
```
|
||||
|
||||
!!! note
|
||||
If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
|
||||
|
||||
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# HTTP Server Setup
|
||||
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 20.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
@@ -12,7 +12,7 @@ To enable HTTPS access to NetBox, you'll need a valid SSL certificate. You can p
|
||||
The command below can be used to generate a self-signed certificate for testing purposes, however it is strongly recommended to use a certificate from a trusted authority in production. Two files will be created: the public certificate (`netbox.crt`) and the private key (`netbox.key`). The certificate is published to the world, whereas the private key must be kept secret at all times.
|
||||
|
||||
```no-highlight
|
||||
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/ssl/private/netbox.key \
|
||||
-out /etc/ssl/certs/netbox.crt
|
||||
```
|
||||
@@ -26,26 +26,27 @@ The above command will prompt you for additional details of the certificate; all
|
||||
Begin by installing nginx:
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y nginx
|
||||
# apt-get install -y nginx
|
||||
```
|
||||
|
||||
Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
|
||||
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
|
||||
```
|
||||
|
||||
Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
|
||||
|
||||
```no-highlight
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
sudo ln -s /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
|
||||
# cd /etc/nginx/sites-enabled/
|
||||
# rm default
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
```
|
||||
|
||||
Finally, restart the `nginx` service to use the new configuration.
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart nginx
|
||||
# service nginx restart
|
||||
```
|
||||
|
||||
### Option B: Apache
|
||||
@@ -53,26 +54,26 @@ sudo systemctl restart nginx
|
||||
Begin by installing Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y apache2
|
||||
# apt-get install -y apache2
|
||||
```
|
||||
|
||||
Next, copy the default configuration file to `/etc/apache2/sites-available/`. Be sure to modify the `ServerName` parameter appropriately.
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
# cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
```
|
||||
|
||||
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
# a2enmod ssl proxy proxy_http headers
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
## Confirm Connectivity
|
||||
|
||||
At this point, you should be able to connect to the HTTPS service at the server name or IP address you provided.
|
||||
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
|
||||
|
||||
!!! info
|
||||
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
|
||||
@@ -90,5 +91,5 @@ If you are unable to connect to the HTTP server, check that:
|
||||
If you are able to connect but receive a 502 (bad gateway) error, check the following:
|
||||
|
||||
* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
|
||||
* Nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
|
||||
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
|
||||
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
|
||||
|
||||
@@ -9,13 +9,13 @@ This guide explains how to implement LDAP authentication using an external serve
|
||||
On Ubuntu:
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
# apt-get install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
```
|
||||
|
||||
On CentOS:
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y openldap-devel
|
||||
# yum install -y openldap-devel
|
||||
```
|
||||
|
||||
### Install django-auth-ldap
|
||||
@@ -23,14 +23,15 @@ sudo yum install -y openldap-devel
|
||||
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
|
||||
|
||||
```no-highlight
|
||||
source /opt/netbox/venv/bin/activate
|
||||
pip3 install django-auth-ldap
|
||||
# cd /opt/netbox/
|
||||
# source venv/bin/activate
|
||||
(venv) # 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
|
||||
(venv) # echo django-auth-ldap >> local_requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -41,7 +42,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
|
||||
### General Server Configuration
|
||||
|
||||
@@ -142,28 +143,17 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
|
||||
`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
|
||||
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
|
||||
|
||||
```python
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'netbox_auth_log': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': '/opt/netbox/logs/django-ldap-debug.log',
|
||||
'maxBytes': 1024 * 500,
|
||||
'backupCount': 5,
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django_auth_ldap': {
|
||||
'handlers': ['netbox_auth_log'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
},
|
||||
}
|
||||
import logging, logging.handlers
|
||||
logfile = "/opt/netbox/logs/django-ldap-debug.log"
|
||||
my_logger = logging.getLogger('django_auth_ldap')
|
||||
my_logger.setLevel(logging.DEBUG)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
logfile, maxBytes=1024 * 500, backupCount=5
|
||||
)
|
||||
my_logger.addHandler(handler)
|
||||
```
|
||||
|
||||
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Installation
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.2. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
@@ -11,14 +9,6 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|------------|-----------------|
|
||||
| Python | 3.6 |
|
||||
| PostgreSQL | 9.6 |
|
||||
| Redis | 4.0 |
|
||||
|
||||
Below is a simplified overview of the NetBox application stack for reference:
|
||||
|
||||

|
||||
|
||||
@@ -38,14 +38,14 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago
|
||||
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Review the Release Notes
|
||||
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
|
||||
|
||||
## Update Dependencies to Required Versions
|
||||
|
||||
@@ -14,7 +14,7 @@ NetBox v2.9.0 and later requires the following:
|
||||
| PostgreSQL | 9.6 |
|
||||
| Redis | 4.0 |
|
||||
|
||||
## Install the Latest Release
|
||||
## Install the Latest Code
|
||||
|
||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
|
||||
|
||||
@@ -25,36 +25,47 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
|
||||
Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -sfn netbox-X.Y.Z/ netbox
|
||||
```
|
||||
|
||||
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
|
||||
Copy the 'configuration.py' you created when first installing to the new version:
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
Copy your local requirements file if used:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
Also copy the LDAP configuration if using LDAP:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
|
||||
```
|
||||
|
||||
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
|
||||
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
|
||||
```
|
||||
|
||||
Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
|
||||
Also make sure to copy over any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
|
||||
sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/scripts/
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
|
||||
# cp netbox-X.Y.Z/gunicorn.py netbox/gunicorn.py
|
||||
```
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
@@ -62,9 +73,10 @@ sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
|
||||
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
|
||||
|
||||
```no-highlight
|
||||
cd /opt/netbox
|
||||
sudo git checkout master
|
||||
sudo git pull origin master
|
||||
# cd /opt/netbox
|
||||
# git checkout master
|
||||
# git pull origin master
|
||||
# git status
|
||||
```
|
||||
|
||||
## Run the Upgrade Script
|
||||
@@ -72,10 +84,10 @@ sudo git pull origin master
|
||||
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
|
||||
|
||||
```no-highlight
|
||||
sudo ./upgrade.sh
|
||||
# ./upgrade.sh
|
||||
```
|
||||
|
||||
This script performs the following actions:
|
||||
This script:
|
||||
|
||||
* Destroys and rebuilds the Python virtual environment
|
||||
* Installs all required Python packages (listed in `requirements.txt`)
|
||||
@@ -87,20 +99,23 @@ This script performs the following actions:
|
||||
* Clears all cached data to prevent conflicts with the new release
|
||||
|
||||
!!! note
|
||||
If the upgrade script prompts a warning about unreflected database migrations, this indicates that some change has
|
||||
been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
|
||||
intentionally modifying the database schema.
|
||||
It's possible that the upgrade script will display a notice warning of unreflected database migrations:
|
||||
|
||||
Your models have changes that are not yet reflected in a migration, and so won't be applied.
|
||||
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
|
||||
|
||||
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema.
|
||||
|
||||
## Restart the NetBox Services
|
||||
|
||||
!!! warning
|
||||
If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
|
||||
If you are upgrading from an installation that does not use a Python virtual environment, you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
|
||||
|
||||
Finally, restart the gunicorn and RQ services:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
# sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
!!! note
|
||||
If upgrading from an installation that uses supervisord, please see the instructions for [migrating to systemd](migrating-to-systemd.md). The use of supervisord is no longer supported.
|
||||
It's possible you are still using supervisord instead of systemd. If so, please see the instructions for [migrating to systemd](migrating-to-systemd.md).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.0 KiB |
@@ -1,3 +1,3 @@
|
||||
## Rear Port Templates
|
||||
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024).
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Route Targets
|
||||
|
||||
A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher.
|
||||
|
||||
Each route target can optionally be assigned to a tenant, and may have tags assigned to it.
|
||||
@@ -10,5 +10,3 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b
|
||||
|
||||
!!! note
|
||||
Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
|
||||
|
||||
Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs.
|
||||
|
||||
@@ -12,9 +12,6 @@ Plugins can do a lot, including:
|
||||
|
||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||
|
||||
!!! warning
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
## Plugin Structure
|
||||
@@ -201,37 +198,26 @@ class RandomAnimalView(View):
|
||||
})
|
||||
```
|
||||
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
|
||||
|
||||
### Extending the Base Template
|
||||
|
||||
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
|
||||
|
||||
* `title` - The page title
|
||||
* `header` - The upper portion of the page
|
||||
* `content` - The main page body
|
||||
* `javascript` - A section at the end of the page for including Javascript code
|
||||
|
||||
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create `animal.html`:
|
||||
|
||||
```jinja2
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
<h2 class="text-center" style="margin-top: 200px">
|
||||
{% if animal %}
|
||||
The {{ animal.name|lower }} says
|
||||
{% if config.loud %}
|
||||
{{ animal.sound|upper }}!
|
||||
{% else %}
|
||||
{{ animal.sound }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No animals have been created yet!
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endwith %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
<h2 class="text-center" style="margin-top: 200px">
|
||||
{% if animal %}
|
||||
The {{ animal.name|lower }} says
|
||||
{% if config.loud %}
|
||||
{{ animal.sound|upper }}!
|
||||
{% else %}
|
||||
{{ animal.sound }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No animals have been created yet!
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
```
|
||||
@@ -342,9 +328,6 @@ A `PluginMenuButton` has the following attributes:
|
||||
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
|
||||
* `permissions` - A list of permissions required to display this button (optional)
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
|
||||
## Extending Core Templates
|
||||
|
||||
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
|
||||
|
||||
@@ -64,15 +64,6 @@ PLUGINS_CONFIG = {
|
||||
}
|
||||
```
|
||||
|
||||
### Run Database Migrations
|
||||
|
||||
If the plugin introduces new database models, run the provided schema migrations:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py migrate
|
||||
```
|
||||
|
||||
### Collect Static Files
|
||||
|
||||
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
|
||||
|
||||
@@ -1 +1 @@
|
||||
version-2.10.md
|
||||
version-2.9.md
|
||||
@@ -1,140 +0,0 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10-beta1 (2020-11-17)
|
||||
|
||||
**NOTE:** This release completely removes support for embedded graphs.
|
||||
|
||||
**NOTE:** The Django templating language (DTL) is no longer supported for export templates. Ensure that all export templates use Jinja2 before upgrading.
|
||||
|
||||
### New Features
|
||||
|
||||
#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259))
|
||||
|
||||
This release introduces support for modeling L3VPN route targets, which can be used to control the redistribution of advertised prefixes among VRFs. Each VRF may be assigned one or more route targets in the import and/or export direction. Like VRFs, route targets may be assigned to tenants and support tag assignment.
|
||||
|
||||
#### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436))
|
||||
|
||||
The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
|
||||
|
||||
```no-highlight
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/dcim/sites/ \
|
||||
--data '[{"id": 10}, {"id": 11}, {"id": 12}]'
|
||||
```
|
||||
|
||||
#### REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882))
|
||||
|
||||
Similar to bulk deletion, the REST API also now supports bulk updates. Send a `PUT` or `PATCH` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object and the attribute(s) to be updated. For example, to set a description for sites with IDs 10 and 11, issue the following request:
|
||||
|
||||
```no-highlight
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/dcim/sites/ \
|
||||
--data '[{"id": 10, "description": "Foo"}, {"id": 11, "description": "Bar"}]'
|
||||
```
|
||||
|
||||
#### Reimplementation of Custom Fields ([#4878](https://github.com/netbox-community/netbox/issues/4878))
|
||||
|
||||
NetBox v2.10 introduces a completely overhauled approach to custom fields. Whereas previous versions used CustomFieldValue instances to store values, custom field data is now stored directly on each model instance as JSON data and may be accessed using the `cf` property:
|
||||
|
||||
```python
|
||||
>>> site = Site.objects.first()
|
||||
>>> site.cf
|
||||
{'site_code': 'US-RAL01'}
|
||||
>>> site.cf['foo'] = 'ABC'
|
||||
>>> site.full_clean()
|
||||
>>> site.save()
|
||||
>>> site = Site.objects.first()
|
||||
>>> site.cf
|
||||
{'foo': 'ABC', 'site_code': 'US-RAL01'}
|
||||
```
|
||||
|
||||
Additionally, custom selection field choices are now defined on the CustomField model within the admin UI, which greatly simplifies working with choice values.
|
||||
|
||||
#### Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900))
|
||||
|
||||
All end-to-end cable paths are now cached using the new CablePath backend model. This allows NetBox to now immediately return the complete path originating from any endpoint directly from the database, rather than having to trace each cable recursively. It also resolves some systemic validation issues present in the original implementation.
|
||||
|
||||
**Note:** As part of this change, cable traces will no longer traverse circuits: A circuit termination will be considered the origin or destination of an end-to-end path.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields
|
||||
* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines
|
||||
* [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI
|
||||
* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the use of multiple port numbers when defining a service
|
||||
* [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `<app>.<model>` string
|
||||
* [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status
|
||||
* [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view
|
||||
* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Support tenant assignment for aggregates
|
||||
* [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields
|
||||
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window
|
||||
* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`)
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy
|
||||
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
|
||||
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates
|
||||
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method
|
||||
* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models
|
||||
* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list
|
||||
* [#5225](https://github.com/netbox-community/netbox/issues/5225) - Circuit termination port speed is now an optional field
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete)
|
||||
* Added the `/extras/content-types/` endpoint for Django ContentTypes
|
||||
* 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
|
||||
* 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
|
||||
* dcim.ConsoleServerPort:
|
||||
* 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`
|
||||
* dcim.Interface:
|
||||
* 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`
|
||||
* dcim.PowerOutlet:
|
||||
* 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
|
||||
* 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`
|
||||
* dcim.VirtualChassis: Added `custom_fields`
|
||||
* extras.ExportTemplate: The `template_language` field has been removed
|
||||
* extras.Graph: This API endpoint has been removed (see #4349)
|
||||
* extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `<app>.<model>`
|
||||
* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `<app>.<model>`
|
||||
* ipam.Aggregate: Added `tenant` field
|
||||
* ipam.RouteTarget: New endpoint
|
||||
* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers
|
||||
* ipam.VRF: Added `import_targets` and `export_targets` fields
|
||||
* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
|
||||
@@ -1,154 +1,5 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.10 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5337](https://github.com/netbox-community/netbox/issues/5337) - Add "splice" type for pass-through ports
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5235](https://github.com/netbox-community/netbox/issues/5235) - Fix exception when editing IP address with a NAT IP assigned to a non-racked device
|
||||
* [#5345](https://github.com/netbox-community/netbox/issues/5345) - Fix non-deterministic ordering of prefixes and IP addresses
|
||||
* [#5350](https://github.com/netbox-community/netbox/issues/5350) - Filter available racks by selected group when creating a rack reservation
|
||||
|
||||
---
|
||||
|
||||
## v2.9.9 (2020-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests
|
||||
* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table
|
||||
* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device
|
||||
* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object
|
||||
* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks
|
||||
* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view
|
||||
* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone
|
||||
* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region
|
||||
|
||||
---
|
||||
|
||||
## v2.9.8 (2020-10-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4559](https://github.com/netbox-community/netbox/issues/4559) - Improve device/VM context data rendering performance
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#3672](https://github.com/netbox-community/netbox/issues/3672) - Fix a caching issue causing incorrect related object counts in API responses
|
||||
* [#5113](https://github.com/netbox-community/netbox/issues/5113) - Fix incorrect caching of permission object assignments to user groups in the admin panel
|
||||
* [#5243](https://github.com/netbox-community/netbox/issues/5243) - Redirect user to appropriate tab after modifying device components
|
||||
* [#5273](https://github.com/netbox-community/netbox/issues/5273) - Fix exception when validating a new permission with no models selected
|
||||
* [#5282](https://github.com/netbox-community/netbox/issues/5282) - Fix high CPU load when LDAP authentication is enabled
|
||||
* [#5285](https://github.com/netbox-community/netbox/issues/5285) - Plugins no longer need to define `app_name` for API URLs to be included in the root view
|
||||
|
||||
---
|
||||
|
||||
## v2.9.7 (2020-10-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5231](https://github.com/netbox-community/netbox/issues/5231) - Fix KeyError exception when viewing object with custom link and debugging is disabled
|
||||
|
||||
---
|
||||
|
||||
## v2.9.6 (2020-10-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled
|
||||
|
||||
---
|
||||
|
||||
## v2.9.5 (2020-10-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
|
||||
* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
|
||||
* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
|
||||
* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
|
||||
* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
|
||||
* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
|
||||
* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
|
||||
* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
|
||||
* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
|
||||
* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
|
||||
* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
|
||||
* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type
|
||||
* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
|
||||
* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
|
||||
* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
|
||||
|
||||
---
|
||||
|
||||
## v2.9.4 (2020-09-23)
|
||||
|
||||
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
|
||||
|
||||
**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
|
||||
* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
|
||||
* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
|
||||
* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
|
||||
* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
|
||||
* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
|
||||
* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
|
||||
* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
|
||||
* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
|
||||
* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
|
||||
* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
|
||||
* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
|
||||
* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
|
||||
* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
|
||||
* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
|
||||
* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
|
||||
* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
|
||||
|
||||
---
|
||||
|
||||
## v2.9.3 (2020-09-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
|
||||
* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
|
||||
* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
|
||||
* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
|
||||
* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
|
||||
* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
|
||||
* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
|
||||
* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
|
||||
* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
|
||||
* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
|
||||
* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
|
||||
* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
|
||||
* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
|
||||
* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
|
||||
|
||||
---
|
||||
|
||||
## v2.9.2 (2020-08-27)
|
||||
|
||||
### Enhancements
|
||||
@@ -245,7 +96,6 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
|
||||
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
|
||||
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
|
||||
|
||||
### REST API Changes
|
||||
|
||||
|
||||
@@ -468,16 +468,16 @@ http://netbox/api/dcim/sites/ \
|
||||
]
|
||||
```
|
||||
|
||||
### Updating an Object
|
||||
### Modifying an Object
|
||||
|
||||
To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified.
|
||||
|
||||
```no-highlight
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/ipam/prefixes/18691/ \
|
||||
--data '{"status": "reserved"}' | jq '.'
|
||||
> -H "Authorization: Token $TOKEN" \
|
||||
> -H "Content-Type: application/json" \
|
||||
> http://netbox/api/ipam/prefixes/18691/ \
|
||||
> --data '{"status": "reserved"}' | jq '.'
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -515,23 +515,6 @@ http://netbox/api/ipam/prefixes/18691/ \
|
||||
!!! note "PUT versus PATCH"
|
||||
The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended.
|
||||
|
||||
### Updating Multiple Objects
|
||||
|
||||
Multiple objects can be updated simultaneously by issuing a `PUT` or `PATCH` request to a model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted and the attributes to be updated. For example, to update sites with IDs 10 and 11 to a status of "active", issue the following request:
|
||||
|
||||
```no-highlight
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/dcim/sites/ \
|
||||
--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]'
|
||||
```
|
||||
|
||||
Note that there is no requirement for the attributes to be identical among objects. For instance, it's possible to update the status of one site along with the name of another in the same request.
|
||||
|
||||
!!! note
|
||||
The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated.
|
||||
|
||||
### Deleting an Object
|
||||
|
||||
To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body.
|
||||
@@ -546,18 +529,3 @@ Note that `DELETE` requests do not return any data: If successful, the API will
|
||||
|
||||
!!! note
|
||||
You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes.
|
||||
|
||||
### Deleting Multiple Objects
|
||||
|
||||
NetBox supports the simultaneous deletion of multiple objects of the same type by issuing a `DELETE` request to the model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
|
||||
|
||||
```no-highlight
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/dcim/sites/ \
|
||||
--data '[{"id": 10}, {"id": 11}, {"id": 12}]'
|
||||
```
|
||||
|
||||
!!! note
|
||||
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
|
||||
|
||||
@@ -49,6 +49,7 @@ nav:
|
||||
- Custom Links: 'additional-features/custom-links.md'
|
||||
- Custom Scripts: 'additional-features/custom-scripts.md'
|
||||
- Export Templates: 'additional-features/export-templates.md'
|
||||
- Graphs: 'additional-features/graphs.md'
|
||||
- NAPALM: 'additional-features/napalm.md'
|
||||
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
|
||||
- Reports: 'additional-features/reports.md'
|
||||
@@ -75,7 +76,6 @@ nav:
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Version 2.10: 'release-notes/version-2.10.md'
|
||||
- Version 2.9: 'release-notes/version-2.9.md'
|
||||
- Version 2.8: 'release-notes/version-2.8.md'
|
||||
- Version 2.7: 'release-notes/version-2.7.md'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from netbox.api import WritableNestedSerializer
|
||||
from utilities.api import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCircuitSerializer',
|
||||
|
||||
@@ -3,11 +3,11 @@ from rest_framework import serializers
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
|
||||
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
|
||||
from dcim.api.serializers import ConnectedEndpointSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class CircuitTerminationSerializer(ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
@@ -77,6 +77,5 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
|
||||
'connected_endpoint_reachable'
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from utilities.api import OrderedDefaultRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits import filters
|
||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from dcim.api.views import PathEndpointMixin
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from extras.models import Graph
|
||||
from utilities.api import ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -28,6 +32,16 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
|
||||
@action(detail=True)
|
||||
def graphs(self, request, pk):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular provider.
|
||||
"""
|
||||
provider = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
#
|
||||
# Circuit Types
|
||||
@@ -47,7 +61,9 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')),
|
||||
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
|
||||
'site', 'connected_endpoint__device'
|
||||
)),
|
||||
'type', 'tenant', 'provider',
|
||||
).prefetch_related('tags')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
@@ -58,9 +74,9 @@ class CircuitViewSet(CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class CircuitTerminationViewSet(ModelViewSet):
|
||||
queryset = CircuitTermination.objects.prefetch_related(
|
||||
'circuit', 'site', '_path__destination', 'cable'
|
||||
'circuit', 'site', 'connected_endpoint__device', 'cable'
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filters.CircuitTerminationFilterSet
|
||||
|
||||
@@ -23,15 +23,6 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONED, 'Decommissioned'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_DEPROVISIONING: 'warning',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_PROVISIONING: 'primary',
|
||||
STATUS_OFFLINE: 'danger',
|
||||
STATUS_DECOMMISSIONED: 'default',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CircuitTerminations
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
|
||||
from dcim.models import Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
@@ -145,7 +144,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class CircuitTerminationFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -303,24 +303,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
|
||||
#
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0019_nullbooleanfield_to_booleanfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='provider',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
]
|
||||
@@ -1,49 +0,0 @@
|
||||
import sys
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def cache_cable_peers(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
|
||||
if 'test' not in sys.argv:
|
||||
print(f"\n Updating circuit termination cable peers...", flush=True)
|
||||
ct = ContentType.objects.get_for_model(CircuitTermination)
|
||||
for cable in Cable.objects.filter(termination_a_type=ct):
|
||||
CircuitTermination.objects.filter(pk=cable.termination_a_id).update(
|
||||
_cable_peer_type_id=cable.termination_b_type_id,
|
||||
_cable_peer_id=cable.termination_b_id
|
||||
)
|
||||
for cable in Cable.objects.filter(termination_b_type=ct):
|
||||
CircuitTermination.objects.filter(pk=cable.termination_b_id).update(
|
||||
_cable_peer_type_id=cable.termination_a_type_id,
|
||||
_cable_peer_id=cable.termination_a_id
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('circuits', '0020_custom_field_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=cache_cable_peers,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0121_cablepath'),
|
||||
('circuits', '0021_cache_cable_peer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='connected_endpoint',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='connection_status',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1 on 2020-10-09 17:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0022_cablepath'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='port_speed',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 3.1 on 2020-10-15 19:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0023_circuittermination_port_speed_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuit',
|
||||
name='cid',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittype',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittype',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import CableTermination, PathEndpoint
|
||||
from dcim.models import CableTermination
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
@@ -20,18 +22,17 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
stores information pertinent to the user's relationship with the Provider.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
asn = ASNField(
|
||||
@@ -60,6 +61,11 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@@ -99,11 +105,10 @@ class CircuitType(ChangeLoggedModel):
|
||||
"Long Haul," "Metro," or "Out-of-Band".
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@@ -140,7 +145,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
in Kbps.
|
||||
"""
|
||||
cid = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
verbose_name='Circuit ID'
|
||||
)
|
||||
provider = models.ForeignKey(
|
||||
@@ -181,6 +186,11 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
objects = CircuitQuerySet.as_manager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
@@ -192,6 +202,15 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
CircuitStatusChoices.STATUS_DEPROVISIONING: 'warning',
|
||||
CircuitStatusChoices.STATUS_ACTIVE: 'success',
|
||||
CircuitStatusChoices.STATUS_PLANNED: 'info',
|
||||
CircuitStatusChoices.STATUS_PROVISIONING: 'primary',
|
||||
CircuitStatusChoices.STATUS_OFFLINE: 'danger',
|
||||
CircuitStatusChoices.STATUS_DECOMMISSIONED: 'default',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
unique_together = ['provider', 'cid']
|
||||
@@ -216,7 +235,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return CircuitStatusChoices.CSS_CLASSES.get(self.status)
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
def _get_termination(self, side):
|
||||
for ct in self.terminations.all():
|
||||
@@ -233,7 +252,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
return self._get_termination('Z')
|
||||
|
||||
|
||||
class CircuitTermination(PathEndpoint, CableTermination):
|
||||
class CircuitTermination(CableTermination):
|
||||
circuit = models.ForeignKey(
|
||||
to='circuits.Circuit',
|
||||
on_delete=models.CASCADE,
|
||||
@@ -249,11 +268,21 @@ class CircuitTermination(PathEndpoint, CableTermination):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='circuit_terminations'
|
||||
)
|
||||
port_speed = models.PositiveIntegerField(
|
||||
verbose_name='Port speed (Kbps)',
|
||||
connected_endpoint = models.OneToOneField(
|
||||
to='dcim.Interface',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
port_speed = models.PositiveIntegerField(
|
||||
verbose_name='Port speed (Kbps)'
|
||||
)
|
||||
upstream_speed = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
||||
@@ -2,9 +2,13 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
@@ -60,7 +64,9 @@ class CircuitTable(BaseTable):
|
||||
viewname='circuits:provider',
|
||||
args=[Accessor('provider__slug')]
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import Site
|
||||
from extras.models import Graph
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
@@ -32,9 +35,6 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'provider-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'asn': 1234,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -46,6 +46,27 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_provider_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Providers.
|
||||
"""
|
||||
provider = self.model.objects.first()
|
||||
ct = ContentType.objects.get(app_label='circuits', model='provider')
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('circuits.view_provider')
|
||||
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
|
||||
|
||||
|
||||
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CircuitType
|
||||
@@ -64,9 +85,6 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'circuit-type-6',
|
||||
},
|
||||
)
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -82,9 +100,6 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
class CircuitTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Circuit
|
||||
brief_fields = ['cid', 'id', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -153,10 +168,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = (
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
)
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@@ -174,7 +189,3 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
'port_speed': 200000,
|
||||
},
|
||||
]
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'port_speed': 123456
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.test import TestCase
|
||||
from circuits.choices import *
|
||||
from circuits.filters import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import Cable, Region, Site
|
||||
from dcim.models import Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ class ProviderTestCase(TestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
CircuitTermination.objects.bulk_create((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000),
|
||||
))
|
||||
|
||||
def test_id(self):
|
||||
@@ -176,9 +176,9 @@ class CircuitTestCase(TestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000),
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@@ -286,8 +286,6 @@ class CircuitTerminationTestCase(TestCase):
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
|
||||
|
||||
def test_term_side(self):
|
||||
params = {'term_side': 'A'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
@@ -315,13 +313,3 @@ class CircuitTerminationTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from dcim.views import CableCreateView, PathTraceView
|
||||
from dcim.views import CableCreateView, CableTraceView
|
||||
from extras.views import ObjectChangeLogView
|
||||
from . import views
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -45,6 +45,6 @@ urlpatterns = [
|
||||
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
|
||||
path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from netbox.views import generic
|
||||
from extras.models import Graph
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -16,14 +20,14 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
class ProviderListView(ObjectListView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
class ProviderView(generic.ObjectView):
|
||||
class ProviderView(ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
def get(self, request, slug):
|
||||
@@ -34,46 +38,48 @@ class ProviderView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
).annotate_sites()
|
||||
show_graphs = Graph.objects.filter(type__model='provider').exists()
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table.columns.hide('provider')
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
'circuits_table': circuits_table,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
class ProviderEditView(generic.ObjectEditView):
|
||||
class ProviderEditView(ObjectEditView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderForm
|
||||
template_name = 'circuits/provider_edit.html'
|
||||
|
||||
|
||||
class ProviderDeleteView(generic.ObjectDeleteView):
|
||||
class ProviderDeleteView(ObjectDeleteView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
class ProviderBulkImportView(generic.BulkImportView):
|
||||
class ProviderBulkImportView(BulkImportView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderCSVForm
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
class ProviderBulkEditView(generic.BulkEditView):
|
||||
class ProviderBulkEditView(BulkEditView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
form = forms.ProviderBulkEditForm
|
||||
|
||||
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
class ProviderBulkDeleteView(BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
@@ -83,27 +89,27 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
class CircuitTypeListView(generic.ObjectListView):
|
||||
class CircuitTypeListView(ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
class CircuitTypeEditView(generic.ObjectEditView):
|
||||
class CircuitTypeEditView(ObjectEditView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeForm
|
||||
|
||||
|
||||
class CircuitTypeDeleteView(generic.ObjectDeleteView):
|
||||
class CircuitTypeDeleteView(ObjectDeleteView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
|
||||
class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
class CircuitTypeBulkImportView(BulkImportView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeCSVForm
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
class CircuitTypeBulkDeleteView(BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
@@ -112,7 +118,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitListView(generic.ObjectListView):
|
||||
class CircuitListView(ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
).annotate_sites()
|
||||
@@ -121,14 +127,14 @@ class CircuitListView(generic.ObjectListView):
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitView(generic.ObjectView):
|
||||
queryset = Circuit.objects.all()
|
||||
class CircuitView(ObjectView):
|
||||
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
|
||||
|
||||
def get(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region'
|
||||
'site__region', 'connected_endpoint__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
@@ -136,7 +142,7 @@ class CircuitView(generic.ObjectView):
|
||||
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region'
|
||||
'site__region', 'connected_endpoint__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
@@ -150,23 +156,23 @@ class CircuitView(generic.ObjectView):
|
||||
})
|
||||
|
||||
|
||||
class CircuitEditView(generic.ObjectEditView):
|
||||
class CircuitEditView(ObjectEditView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitForm
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
|
||||
|
||||
class CircuitDeleteView(generic.ObjectDeleteView):
|
||||
class CircuitDeleteView(ObjectDeleteView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
class CircuitBulkImportView(generic.BulkImportView):
|
||||
class CircuitBulkImportView(BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitCSVForm
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
class CircuitBulkEditView(BulkEditView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
@@ -175,7 +181,7 @@ class CircuitBulkEditView(generic.BulkEditView):
|
||||
form = forms.CircuitBulkEditForm
|
||||
|
||||
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
class CircuitBulkDeleteView(BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
@@ -183,7 +189,7 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
class CircuitSwapTerminations(ObjectEditView):
|
||||
"""
|
||||
Swap the A and Z terminations of a circuit.
|
||||
"""
|
||||
@@ -256,7 +262,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationEditView(generic.ObjectEditView):
|
||||
class CircuitTerminationEditView(ObjectEditView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
model_form = forms.CircuitTerminationForm
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
@@ -270,5 +276,5 @@ class CircuitTerminationEditView(generic.ObjectEditView):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
|
||||
class CircuitTerminationDeleteView(ObjectDeleteView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim import models
|
||||
from netbox.api import WritableNestedSerializer
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCableSerializer',
|
||||
@@ -227,46 +228,51 @@ class NestedDeviceSerializer(WritableNestedSerializer):
|
||||
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedConsolePortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedPowerOutletSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedPowerPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedInterfaceSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedRearPortSerializer(WritableNestedSerializer):
|
||||
@@ -299,11 +305,10 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
class NestedInventoryItemSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItem
|
||||
fields = ['id', 'url', 'device', 'name', '_depth']
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -7,57 +7,37 @@ from rest_framework.validators import UniqueTogetherValidator
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import (
|
||||
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from dcim.utils import decompile_path_node
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
|
||||
from ipam.models import VLAN
|
||||
from netbox.api import (
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
)
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.api import (
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
|
||||
WritableNestedSerializer, get_serializer_for_model,
|
||||
)
|
||||
from virtualization.api.nested_serializers import NestedClusterSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
class CableTerminationSerializer(serializers.ModelSerializer):
|
||||
cable_peer_type = serializers.SerializerMethodField(read_only=True)
|
||||
cable_peer = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_cable_peer_type(self, obj):
|
||||
if obj._cable_peer is not None:
|
||||
return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_cable_peer(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the cable termination model.
|
||||
"""
|
||||
if obj._cable_peer is not None:
|
||||
serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._cable_peer, context=context).data
|
||||
return None
|
||||
|
||||
|
||||
class ConnectedEndpointSerializer(ValidatedModelSerializer):
|
||||
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoint = serializers.SerializerMethodField(read_only=True)
|
||||
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
def get_connected_endpoint_type(self, obj):
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
|
||||
if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None:
|
||||
return '{}.{}'.format(
|
||||
obj.connected_endpoint._meta.app_label,
|
||||
obj.connected_endpoint._meta.model_name
|
||||
)
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
@@ -65,17 +45,14 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
|
||||
"""
|
||||
Return the appropriate serializer for the type of connected object.
|
||||
"""
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._path.destination, context=context).data
|
||||
return None
|
||||
if getattr(obj, 'connected_endpoint', None) is None:
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
|
||||
def get_connected_endpoint_reachable(self, obj):
|
||||
if obj._path is not None:
|
||||
return obj._path.is_active
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
data = serializer(obj.connected_endpoint, context=context).data
|
||||
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
@@ -191,7 +168,7 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
occupied = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
@@ -199,7 +176,7 @@ class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializ
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields']
|
||||
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
@@ -475,7 +452,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.DictField()
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@@ -488,12 +465,12 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@@ -506,12 +483,12 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer,
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@@ -534,13 +511,12 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer,
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable',
|
||||
'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
|
||||
'connected_endpoint_reachable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@@ -553,13 +529,12 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable',
|
||||
'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
|
||||
'connected_endpoint_reachable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
@@ -579,9 +554,8 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
|
||||
'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
|
||||
'count_ipaddresses',
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
|
||||
]
|
||||
|
||||
# TODO: This validation should be handled by Interface.clean()
|
||||
@@ -605,7 +579,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
|
||||
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
@@ -613,10 +587,7 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Val
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer',
|
||||
'cable_peer_type', 'tags',
|
||||
]
|
||||
fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags']
|
||||
|
||||
|
||||
class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
@@ -630,7 +601,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'name', 'label']
|
||||
|
||||
|
||||
class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
|
||||
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
@@ -641,7 +612,7 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Va
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
|
||||
'cable_peer', 'cable_peer_type', 'tags',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -665,13 +636,12 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
# Provide a default value to satisfy UniqueTogetherValidator
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
|
||||
'discovered', 'description', 'tags', '_depth',
|
||||
'discovered', 'description', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -679,7 +649,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
@@ -697,7 +667,6 @@ class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
|
||||
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
def _get_termination(self, obj, side):
|
||||
@@ -737,50 +706,6 @@ class TracedCableSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CablePathSerializer(serializers.ModelSerializer):
|
||||
origin_type = ContentTypeField(read_only=True)
|
||||
origin = serializers.SerializerMethodField(read_only=True)
|
||||
destination_type = ContentTypeField(read_only=True)
|
||||
destination = serializers.SerializerMethodField(read_only=True)
|
||||
path = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CablePath
|
||||
fields = [
|
||||
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_origin(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the origin.
|
||||
"""
|
||||
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.origin, context=context).data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_destination(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the destination, if any.
|
||||
"""
|
||||
if obj.destination_id is not None:
|
||||
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.destination, context=context).data
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for node in obj.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
ct = ContentType.objects.get_for_id(ct_id)
|
||||
# TODO: Return the object URL
|
||||
ret.append(f'{ct.app_label}.{ct.model}:{object_id}')
|
||||
return ret
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
@@ -788,43 +713,37 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
interface_a = serializers.SerializerMethodField()
|
||||
interface_b = NestedInterfaceSerializer(source='connected_endpoint')
|
||||
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable']
|
||||
fields = ['interface_a', 'interface_b', 'connection_status']
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
|
||||
def get_interface_a(self, obj):
|
||||
context = {'request': self.context['request']}
|
||||
return NestedInterfaceSerializer(instance=obj, context=context).data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
|
||||
def get_connected_endpoint_reachable(self, obj):
|
||||
if obj._path is not None:
|
||||
return obj._path.is_active
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
|
||||
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
site = NestedSiteSerializer()
|
||||
rack_group = NestedRackGroupSerializer(
|
||||
@@ -836,15 +755,10 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count']
|
||||
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
|
||||
|
||||
|
||||
class PowerFeedSerializer(
|
||||
TaggedObjectSerializer,
|
||||
CableTerminationSerializer,
|
||||
ConnectedEndpointSerializer,
|
||||
CustomFieldModelSerializer
|
||||
):
|
||||
class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
@@ -874,7 +788,5 @@ class PowerFeedSerializer(
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'cable',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from utilities.api import OrderedDefaultRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@@ -17,20 +17,21 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
from circuits.models import Circuit
|
||||
from dcim import filters
|
||||
from dcim.models import (
|
||||
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.api import (
|
||||
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
|
||||
)
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.metadata import ContentTypeMetadata
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -46,7 +47,7 @@ class DCIMRootView(APIRootView):
|
||||
|
||||
# Mixins
|
||||
|
||||
class PathEndpointMixin(object):
|
||||
class CableTraceMixin(object):
|
||||
|
||||
@action(detail=True, url_path='trace')
|
||||
def trace(self, request, pk):
|
||||
@@ -58,10 +59,7 @@ class PathEndpointMixin(object):
|
||||
# Initialize the path array
|
||||
path = []
|
||||
|
||||
for near_end, cable, far_end in obj.trace():
|
||||
if near_end is None:
|
||||
# Split paths
|
||||
break
|
||||
for near_end, cable, far_end in obj.trace()[0]:
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
@@ -81,20 +79,6 @@ class PathEndpointMixin(object):
|
||||
return Response(path)
|
||||
|
||||
|
||||
class PassThroughPortMixin(object):
|
||||
|
||||
@action(detail=True, url_path='paths')
|
||||
def paths(self, request, pk):
|
||||
"""
|
||||
Return all CablePaths which traverse a given pass-through port.
|
||||
"""
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
|
||||
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
@@ -129,6 +113,16 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilterSet
|
||||
|
||||
@action(detail=True)
|
||||
def graphs(self, request, pk):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular site.
|
||||
"""
|
||||
site = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
@@ -342,7 +336,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class DeviceViewSet(CustomFieldModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@@ -369,6 +363,17 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
@action(detail=True)
|
||||
def graphs(self, request, pk):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular Device.
|
||||
"""
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[
|
||||
Parameter(
|
||||
@@ -391,7 +396,9 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
if device.platform is None:
|
||||
raise ServiceUnavailable("No platform is configured for this device.")
|
||||
if not device.platform.napalm_driver:
|
||||
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
|
||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
|
||||
device.platform
|
||||
))
|
||||
|
||||
# Check for primary IP address from NetBox object
|
||||
if device.primary_ip:
|
||||
@@ -400,25 +407,21 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
# Raise exception for no IP address and no Name if device.name does not exist
|
||||
if not device.name:
|
||||
raise ServiceUnavailable(
|
||||
"This device does not have a primary IP address or device name to lookup configured."
|
||||
)
|
||||
"This device does not have a primary IP address or device name to lookup configured.")
|
||||
try:
|
||||
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||
host = socket.gethostbyname(device.name)
|
||||
except socket.gaierror:
|
||||
# Name lookup failure
|
||||
raise ServiceUnavailable(
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
|
||||
f"setup name resolution.")
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
|
||||
|
||||
# Check that NAPALM is installed
|
||||
try:
|
||||
import napalm
|
||||
from napalm.base.exceptions import ModuleImportError
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'napalm':
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
raise e
|
||||
except ImportError:
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
|
||||
# Validate the configured driver
|
||||
try:
|
||||
@@ -489,47 +492,59 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsolePortFilterSet
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related(
|
||||
'device', '_path__destination', 'cable', '_cable_peer', 'tags'
|
||||
)
|
||||
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filterset_class = filters.ConsoleServerPortFilterSet
|
||||
|
||||
|
||||
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
class PowerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerPortFilterSet
|
||||
|
||||
|
||||
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filterset_class = filters.PowerOutletFilterSet
|
||||
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
|
||||
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
|
||||
).filter(
|
||||
device__isnull=False
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filters.InterfaceFilterSet
|
||||
|
||||
@action(detail=True)
|
||||
def graphs(self, request, pk):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular interface.
|
||||
"""
|
||||
interface = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
|
||||
return Response(serializer.data)
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
|
||||
class FrontPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filters.FrontPortFilterSet
|
||||
|
||||
|
||||
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
class RearPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filters.RearPortFilterSet
|
||||
@@ -552,26 +567,32 @@ class InventoryItemViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path').filter(
|
||||
_path__destination_id__isnull=False
|
||||
queryset = ConsolePort.objects.prefetch_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
)
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsoleConnectionFilterSet
|
||||
|
||||
|
||||
class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path').filter(
|
||||
_path__destination_id__isnull=False
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
_connected_poweroutlet__isnull=False
|
||||
)
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerConnectionFilterSet
|
||||
|
||||
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.prefetch_related('device', '_path').filter(
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', '_connected_interface__device'
|
||||
).filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_path__destination_id__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
_connected_interface__isnull=False,
|
||||
pk__lt=F('_connected_interface')
|
||||
)
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
filterset_class = filters.InterfaceConnectionFilterSet
|
||||
@@ -620,10 +641,8 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related(
|
||||
'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
|
||||
)
|
||||
class PowerFeedViewSet(CustomFieldModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
|
||||
serializer_class = serializers.PowerFeedSerializer
|
||||
filterset_class = filters.PowerFeedFilterSet
|
||||
|
||||
@@ -678,7 +697,7 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
device__name=peer_device_name,
|
||||
name=peer_interface_name
|
||||
)
|
||||
local_interface = peer_interface.connected_endpoint
|
||||
local_interface = peer_interface._connected_interface
|
||||
|
||||
if local_interface is None:
|
||||
return Response()
|
||||
|
||||
@@ -21,14 +21,6 @@ class SiteStatusChoices(ChoiceSet):
|
||||
(STATUS_RETIRED, 'Retired'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_STAGING: 'primary',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_DECOMMISSIONING: 'warning',
|
||||
STATUS_RETIRED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
@@ -82,14 +74,6 @@ class RackStatusChoices(ChoiceSet):
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_RESERVED: 'warning',
|
||||
STATUS_AVAILABLE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_ACTIVE: 'primary',
|
||||
STATUS_DEPRECATED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class RackDimensionUnitChoices(ChoiceSet):
|
||||
|
||||
@@ -163,16 +147,6 @@ class DeviceStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_OFFLINE: 'warning',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_STAGED: 'primary',
|
||||
STATUS_FAILED: 'danger',
|
||||
STATUS_INVENTORY: 'default',
|
||||
STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConsolePorts
|
||||
@@ -840,9 +814,6 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
class PortTypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_8P8C = '8p8c'
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
@@ -856,16 +827,12 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_MPO = 'mpo'
|
||||
TYPE_LSH = 'lsh'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
'Copper',
|
||||
(
|
||||
(TYPE_8P8C, '8P8C'),
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
@@ -884,7 +851,6 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_SC, 'SC'),
|
||||
(TYPE_SC_APC, 'SC/APC'),
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -961,12 +927,6 @@ class CableStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_CONNECTED: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
|
||||
class CableLengthUnitChoices(ChoiceSet):
|
||||
|
||||
@@ -1001,13 +961,6 @@ class PowerFeedStatusChoices(ChoiceSet):
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_OFFLINE: 'warning',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_FAILED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedTypeChoices(ChoiceSet):
|
||||
|
||||
@@ -1019,11 +972,6 @@ class PowerFeedTypeChoices(ChoiceSet):
|
||||
(TYPE_REDUNDANT, 'Redundant'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
TYPE_PRIMARY: 'success',
|
||||
TYPE_REDUNDANT: 'info',
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedSupplyChoices(ChoiceSet):
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
#
|
||||
|
||||
REARPORT_POSITIONS_MIN = 1
|
||||
REARPORT_POSITIONS_MAX = 1024
|
||||
REARPORT_POSITIONS_MAX = 64
|
||||
|
||||
|
||||
#
|
||||
@@ -59,6 +59,12 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
# Cabling and connections
|
||||
#
|
||||
|
||||
# Console/power/interface connection statuses
|
||||
CONNECTION_STATUS_CHOICES = [
|
||||
[False, 'Not Connected'],
|
||||
[True, 'Connected'],
|
||||
]
|
||||
|
||||
# Cable endpoint types
|
||||
CABLE_TERMINATION_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=(
|
||||
@@ -77,13 +83,12 @@ CABLE_TERMINATION_MODELS = Q(
|
||||
)
|
||||
|
||||
COMPATIBLE_TERMINATION_TYPES = {
|
||||
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'consoleport': ['consoleserverport', 'frontport', 'rearport'],
|
||||
'consoleserverport': ['consoleport', 'frontport', 'rearport'],
|
||||
'powerport': ['poweroutlet', 'powerfeed'],
|
||||
'poweroutlet': ['powerport'],
|
||||
'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
|
||||
'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'powerfeed': ['powerport'],
|
||||
'poweroutlet': ['powerport'],
|
||||
'powerport': ['poweroutlet', 'powerfeed'],
|
||||
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class RackElevationSVG:
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
|
||||
if not u['device'] and o['device']:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
||||
14
netbox/dcim/exceptions.py
Normal file
14
netbox/dcim/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class LoopDetected(Exception):
|
||||
"""
|
||||
A loop has been detected while tracing a cable path.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CableTraceSplit(Exception):
|
||||
"""
|
||||
A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and
|
||||
we don't know which one to follow.
|
||||
"""
|
||||
def __init__(self, termination, *args, **kwargs):
|
||||
self.termination = termination
|
||||
@@ -1,11 +1,9 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from netaddr import AddrFormatError, EUI, mac_unix_expanded
|
||||
|
||||
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
||||
from .lookups import PathContains
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
@@ -52,15 +50,3 @@ class MACAddressField(models.Field):
|
||||
if not value:
|
||||
return None
|
||||
return str(self.to_python(value))
|
||||
|
||||
|
||||
class PathField(ArrayField):
|
||||
"""
|
||||
An ArrayField which holds a set of objects, each identified by a (type, ID) tuple.
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
kwargs['base_field'] = models.CharField(max_length=40)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
PathField.register_lookup(PathContains)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count
|
||||
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
@@ -24,7 +23,6 @@ from .models import (
|
||||
|
||||
__all__ = (
|
||||
'CableFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
'ConsolePortTemplateFilterSet',
|
||||
@@ -42,7 +40,6 @@ __all__ = (
|
||||
'InterfaceTemplateFilterSet',
|
||||
'InventoryItemFilterSet',
|
||||
'ManufacturerFilterSet',
|
||||
'PathEndpointFilterSet',
|
||||
'PlatformFilterSet',
|
||||
'PowerConnectionFilterSet',
|
||||
'PowerFeedFilterSet',
|
||||
@@ -665,10 +662,16 @@ class DeviceFilterSet(
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
return queryset.filter(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
else:
|
||||
return queryset.exclude(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
|
||||
def _virtual_chassis_member(self, queryset, name, value):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
@@ -749,76 +752,71 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(django_filters.FilterSet):
|
||||
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
|
||||
class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
connected = django_filters.BooleanFilter(
|
||||
method='filter_connected'
|
||||
)
|
||||
|
||||
def filter_connected(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(_path__is_active=True)
|
||||
else:
|
||||
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
|
||||
|
||||
|
||||
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'name', 'description']
|
||||
fields = ['id', 'name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class ConsoleServerPortFilterSet(
|
||||
BaseFilterSet,
|
||||
DeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'name', 'description']
|
||||
fields = ['id', 'name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerPortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description']
|
||||
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
|
||||
|
||||
|
||||
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'name', 'feed_leg', 'description']
|
||||
fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
|
||||
|
||||
|
||||
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -835,6 +833,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
field_name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
kind = django_filters.CharFilter(
|
||||
method='filter_kind',
|
||||
label='Kind of interface',
|
||||
@@ -861,7 +864,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
|
||||
fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
@@ -911,14 +914,24 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
}.get(value, queryset.none())
|
||||
|
||||
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['id', 'name', 'type', 'description']
|
||||
|
||||
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
@@ -1126,20 +1139,7 @@ class CableFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class ConnectionFilterSet:
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(device_id__in=value)
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
class ConsoleConnectionFilterSet(BaseFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@@ -1154,10 +1154,23 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name']
|
||||
fields = ['name', 'connection_status']
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(connected_endpoint__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'connected_endpoint__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
class PowerConnectionFilterSet(BaseFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@@ -1172,10 +1185,23 @@ class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name']
|
||||
fields = ['name', 'connection_status']
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
class InterfaceConnectionFilterSet(BaseFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@@ -1190,7 +1216,23 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
fields = ['connection_status']
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__site__slug=value) |
|
||||
Q(_connected_interface__device__site__slug=value)
|
||||
)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'_connected_interface__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
class PowerPanelFilterSet(BaseFilterSet):
|
||||
@@ -1242,13 +1284,7 @@ class PowerPanelFilterSet(BaseFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilterSet(
|
||||
BaseFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet,
|
||||
CustomFieldFilterSet,
|
||||
CreatedUpdatedFilterSet
|
||||
):
|
||||
class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -352,18 +352,8 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -377,7 +367,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = (
|
||||
'region', 'site', 'parent', 'name', 'slug', 'description',
|
||||
'site', 'parent', 'name', 'slug', 'description',
|
||||
)
|
||||
|
||||
|
||||
@@ -457,17 +447,14 @@ class RackRoleCSVForm(CSVModelForm):
|
||||
#
|
||||
|
||||
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@@ -483,9 +470,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'comments', 'tags',
|
||||
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag',
|
||||
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
@@ -562,19 +548,9 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
queryset=Rack.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -714,20 +690,10 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -741,7 +707,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'group_id': '$rack_group',
|
||||
'group_id': 'rack',
|
||||
}
|
||||
)
|
||||
units = NumericArrayField(
|
||||
@@ -843,23 +809,15 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackGroup.objects.prefetch_related('site'),
|
||||
@@ -1714,10 +1672,7 @@ class PlatformCSVForm(CSVModelForm):
|
||||
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1725,24 +1680,12 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'group_id': '$rack_group',
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
@@ -1759,10 +1702,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'device_types': '$device_type'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -1784,10 +1724,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
cluster_group = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'clusters': '$cluster'
|
||||
}
|
||||
null_option='None'
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
@@ -1826,6 +1763,27 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
if 'initial' not in kwargs:
|
||||
kwargs['initial'] = {}
|
||||
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
||||
if instance and hasattr(instance, 'device_type'):
|
||||
kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
|
||||
if instance and instance.cluster is not None:
|
||||
kwargs['initial']['cluster_group'] = instance.cluster.group
|
||||
|
||||
if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
|
||||
device_type_id = kwargs['initial']['device_type']
|
||||
manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
|
||||
kwargs['initial']['manufacturer'] = manufacturer_id
|
||||
|
||||
if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
|
||||
cluster_id = kwargs['initial']['cluster']
|
||||
cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
|
||||
kwargs['initial']['cluster_group'] = cluster_group_id
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
@@ -2359,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'device', 'name', 'label', 'type', 'description', 'tags',
|
||||
'device', 'name', 'type', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2432,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2521,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2877,24 +2835,6 @@ class InterfaceBulkEditForm(
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
|
||||
else:
|
||||
# See 4523
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
if site is None:
|
||||
site = interface.device.site
|
||||
elif interface.device.site is not site:
|
||||
site = None
|
||||
break
|
||||
|
||||
if site is not None:
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
|
||||
self.fields['lag'].choices = ()
|
||||
self.fields['lag'].widget.attrs['disabled'] = True
|
||||
|
||||
@@ -3344,13 +3284,6 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
display_field='display_name'
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
@@ -3363,8 +3296,7 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'tags',
|
||||
'name', 'label', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -3373,13 +3305,6 @@ class InventoryItemCreateForm(ComponentCreateForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
part_id = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
@@ -3394,8 +3319,8 @@ class InventoryItemCreateForm(ComponentCreateForm):
|
||||
required=False,
|
||||
)
|
||||
field_order = (
|
||||
'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
|
||||
'description', 'tags',
|
||||
'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'tags',
|
||||
)
|
||||
|
||||
|
||||
@@ -3470,22 +3395,14 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
|
||||
"""
|
||||
Base form for connecting a Cable to a Device component
|
||||
"""
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
termination_b_rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -3507,16 +3424,12 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'rack_id': '$termination_b_rack',
|
||||
}
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
|
||||
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
|
||||
'label', 'color', 'length', 'length_unit',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2,
|
||||
@@ -3607,24 +3520,16 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
|
||||
)
|
||||
|
||||
|
||||
class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
termination_b_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
required=False
|
||||
)
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
termination_b_circuit = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
@@ -3644,16 +3549,12 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
|
||||
'circuit_id': '$termination_b_circuit'
|
||||
}
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type',
|
||||
'status', 'label', 'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
@@ -3661,19 +3562,12 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
display_field='cid'
|
||||
)
|
||||
termination_b_rackgroup = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -3701,16 +3595,12 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'power_panel_id': '$termination_b_powerpanel'
|
||||
}
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
|
||||
'color', 'length', 'length_unit', 'tags',
|
||||
'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
@@ -3718,7 +3608,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class CableForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class CableForm(BootstrapMixin, forms.ModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -3919,18 +3809,10 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
tenant = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
@@ -3979,18 +3861,10 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
#
|
||||
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -4003,18 +3877,10 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -4027,18 +3893,10 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -4061,20 +3919,10 @@ class DeviceSelectionForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -4107,7 +3955,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -4124,7 +3972,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
return instance
|
||||
|
||||
|
||||
class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
|
||||
master = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -4204,19 +4052,9 @@ class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
|
||||
class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -4314,19 +4152,9 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -4343,7 +4171,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = [
|
||||
'region', 'site', 'rack_group', 'name', 'tags',
|
||||
'site', 'rack_group', 'name', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -4378,19 +4206,9 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
queryset=PowerPanel.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -4442,22 +4260,9 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites__powerpanel': '$power_panel'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'powerpanel': '$power_panel'
|
||||
},
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
power_panel = DynamicModelChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
@@ -4482,7 +4287,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
@@ -4492,6 +4297,14 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'phase': StaticSelect2(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize site field
|
||||
if self.instance and hasattr(self.instance, 'power_panel'):
|
||||
self.initial['site'] = self.instance.power_panel.site
|
||||
|
||||
|
||||
class PowerFeedCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.contrib.postgres.fields.array import ArrayContains
|
||||
|
||||
from dcim.utils import object_to_path_node
|
||||
|
||||
|
||||
class PathContains(ArrayContains):
|
||||
|
||||
def get_prep_lookup(self):
|
||||
self.rhs = [object_to_path_node(self.rhs)]
|
||||
return super().get_prep_lookup()
|
||||
@@ -1,81 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.color import no_style
|
||||
from django.db import connection
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
|
||||
from dcim.signals import create_cablepath
|
||||
|
||||
ENDPOINT_MODELS = (
|
||||
CircuitTermination,
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
Interface,
|
||||
PowerFeed,
|
||||
PowerOutlet,
|
||||
PowerPort
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate any missing cable paths among all cable termination objects in NetBox"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force", action='store_true', dest='force',
|
||||
help="Force recalculation of all existing cable paths"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-input", action='store_true', dest='no_input',
|
||||
help="Do not prompt user for any input/confirmation"
|
||||
)
|
||||
|
||||
def handle(self, *model_names, **options):
|
||||
|
||||
# If --force was passed, first delete all existing CablePaths
|
||||
if options['force']:
|
||||
cable_paths = CablePath.objects.all()
|
||||
paths_count = cable_paths.count()
|
||||
|
||||
# Prompt the user to confirm recalculation of all paths
|
||||
if paths_count and not options['no_input']:
|
||||
self.stdout.write(self.style.ERROR("WARNING: Forcing recalculation of all cable paths."))
|
||||
self.stdout.write(
|
||||
f"This will delete and recalculate all {paths_count} existing cable paths. Are you sure?"
|
||||
)
|
||||
confirmation = input("Type yes to confirm: ")
|
||||
if confirmation != 'yes':
|
||||
self.stdout.write(self.style.SUCCESS("Aborting"))
|
||||
return
|
||||
|
||||
# Delete all existing CablePath instances
|
||||
self.stdout.write(f"Deleting {paths_count} existing cable paths...")
|
||||
deleted_count, _ = CablePath.objects.all().delete()
|
||||
self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths')))
|
||||
|
||||
# Reinitialize the model's PK sequence
|
||||
self.stdout.write(f'Resetting database sequence for CablePath model')
|
||||
sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath])
|
||||
with connection.cursor() as cursor:
|
||||
for sql in sequence_sql:
|
||||
cursor.execute(sql)
|
||||
|
||||
# Retrace paths
|
||||
for model in ENDPOINT_MODELS:
|
||||
origins = model.objects.filter(cable__isnull=False)
|
||||
if not options['force']:
|
||||
origins = origins.filter(_path__isnull=True)
|
||||
origins_count = origins.count()
|
||||
if not origins_count:
|
||||
print(f'Found no missing {model._meta.verbose_name} paths; skipping')
|
||||
continue
|
||||
print(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
||||
i = 0
|
||||
for i, obj in enumerate(origins, start=1):
|
||||
create_cablepath(obj)
|
||||
# TODO: Come up with a better progress indicator
|
||||
if not i % 1000:
|
||||
self.stdout.write(f' {i}')
|
||||
self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Finished.'))
|
||||
@@ -1,34 +0,0 @@
|
||||
# Generated by Django 3.1 on 2020-09-16 16:51
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0115_rackreservation_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='frontport',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='frontporttemplate',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearport',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearporttemplate',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
]
|
||||
@@ -1,60 +0,0 @@
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0116_rearport_max_positions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Original CustomFieldModels
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
|
||||
# Added under #5146
|
||||
migrations.AddField(
|
||||
model_name='cable',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerpanel',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rackreservation',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='virtualchassis',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
]
|
||||
@@ -1,44 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0117_custom_field_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# The MPTT will be rebuilt in the following migration. Using dummy values for now.
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='level',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='lft',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='rght',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='tree_id',
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
# Convert ForeignKey to TreeForeignKey
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='parent',
|
||||
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitem'),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
from django.db import migrations
|
||||
import mptt
|
||||
import mptt.managers
|
||||
|
||||
|
||||
def rebuild_mptt(apps, schema_editor):
|
||||
manager = mptt.managers.TreeManager()
|
||||
InventoryItem = apps.get_model('dcim', 'InventoryItem')
|
||||
manager.model = InventoryItem
|
||||
mptt.register(InventoryItem)
|
||||
manager.contribute_to_class(InventoryItem, 'objects')
|
||||
manager.rebuild()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0118_inventoryitem_mptt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=rebuild_mptt,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -1,141 +0,0 @@
|
||||
import sys
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def cache_cable_peers(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
ConsolePort = apps.get_model('dcim', 'ConsolePort')
|
||||
ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort')
|
||||
PowerPort = apps.get_model('dcim', 'PowerPort')
|
||||
PowerOutlet = apps.get_model('dcim', 'PowerOutlet')
|
||||
Interface = apps.get_model('dcim', 'Interface')
|
||||
FrontPort = apps.get_model('dcim', 'FrontPort')
|
||||
RearPort = apps.get_model('dcim', 'RearPort')
|
||||
PowerFeed = apps.get_model('dcim', 'PowerFeed')
|
||||
|
||||
models = (
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
PowerPort,
|
||||
PowerOutlet,
|
||||
Interface,
|
||||
FrontPort,
|
||||
RearPort,
|
||||
PowerFeed
|
||||
)
|
||||
|
||||
if 'test' not in sys.argv:
|
||||
print("\n", end="")
|
||||
|
||||
for model in models:
|
||||
if 'test' not in sys.argv:
|
||||
print(f" Updating {model._meta.verbose_name} cable peers...", flush=True)
|
||||
ct = ContentType.objects.get_for_model(model)
|
||||
for cable in Cable.objects.filter(termination_a_type=ct):
|
||||
model.objects.filter(pk=cable.termination_a_id).update(
|
||||
_cable_peer_type_id=cable.termination_b_type_id,
|
||||
_cable_peer_id=cable.termination_b_id
|
||||
)
|
||||
for cable in Cable.objects.filter(termination_b_type=ct):
|
||||
model.objects.filter(pk=cable.termination_b_id).update(
|
||||
_cable_peer_type_id=cable.termination_a_type_id,
|
||||
_cable_peer_id=cable.termination_a_id
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0119_inventoryitem_mptt_rebuild'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_cable_peer_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_cable_peer_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=cache_cable_peers,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -1,108 +0,0 @@
|
||||
import dcim.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0120_cache_cable_peer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CablePath',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('origin_id', models.PositiveIntegerField()),
|
||||
('destination_id', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('is_split', models.BooleanField(default=False)),
|
||||
('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('origin_type', 'origin_id')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_path',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='connected_endpoint',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='connection_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleserverport',
|
||||
name='connection_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_connected_circuittermination',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_connected_interface',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='connection_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='connected_endpoint',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='connection_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='poweroutlet',
|
||||
name='connection_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_connected_powerfeed',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_connected_poweroutlet',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='connection_status',
|
||||
),
|
||||
]
|
||||
@@ -1,98 +0,0 @@
|
||||
# Generated by Django 3.1 on 2020-10-15 19:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0121_cablepath'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicerole',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicerole',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='model',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='manufacturer',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='manufacturer',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerfeed',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerpanel',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackgroup',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackgroup',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackrole',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackrole',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='region',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='region',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,3 @@
|
||||
from .cables import *
|
||||
from .device_component_templates import *
|
||||
from .device_components import *
|
||||
from .devices import *
|
||||
@@ -9,7 +8,6 @@ from .sites import *
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'Cable',
|
||||
'CablePath',
|
||||
'CableTermination',
|
||||
'ConsolePort',
|
||||
'ConsolePortTemplate',
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from .devices import Device
|
||||
from .device_components import FrontPort, RearPort
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
'CablePath',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Cable(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
"""
|
||||
termination_a_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_a_id = models.PositiveIntegerField()
|
||||
termination_a = GenericForeignKey(
|
||||
ct_field='termination_a_type',
|
||||
fk_field='termination_a_id'
|
||||
)
|
||||
termination_b_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_b_id = models.PositiveIntegerField()
|
||||
termination_b = GenericForeignKey(
|
||||
ct_field='termination_b_type',
|
||||
fk_field='termination_b_id'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableTypeChoices,
|
||||
blank=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableStatusChoices,
|
||||
default=CableStatusChoices.STATUS_CONNECTED
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
color = ColorField(
|
||||
blank=True
|
||||
)
|
||||
length = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
length_unit = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableLengthUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
# Stores the normalized length (in meters) for database ordering
|
||||
_abs_length = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
|
||||
# their associated Devices.
|
||||
_termination_a_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_termination_b_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
|
||||
'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
('termination_a_type', 'termination_a_id'),
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db, field_names, values):
|
||||
"""
|
||||
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
|
||||
"""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
|
||||
instance._orig_termination_a_type_id = instance.termination_a_type_id
|
||||
instance._orig_termination_a_id = instance.termination_a_id
|
||||
instance._orig_termination_b_type_id = instance.termination_b_type_id
|
||||
instance._orig_termination_b_id = instance.termination_b_id
|
||||
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
raise ValidationError('Termination A type has not been specified')
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
if not hasattr(self, 'termination_b_type'):
|
||||
raise ValidationError('Termination B type has not been specified')
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
# If editing an existing Cable instance, check that neither termination has been modified.
|
||||
if self.pk:
|
||||
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
|
||||
if (
|
||||
self.termination_a_type_id != self._orig_termination_a_type_id or
|
||||
self.termination_a_id != self._orig_termination_a_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_a': err_msg
|
||||
})
|
||||
if (
|
||||
self.termination_b_type_id != self._orig_termination_b_type_id or
|
||||
self.termination_b_id != self._orig_termination_b_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_b': err_msg
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# Validate interface types
|
||||
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_a.get_type_display()
|
||||
)
|
||||
})
|
||||
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_b.get_type_display()
|
||||
)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError(
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# Check that two connected RearPorts have the same number of positions (if both are >1)
|
||||
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
|
||||
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
|
||||
if self.termination_a.positions != self.termination_b.positions:
|
||||
raise ValidationError(
|
||||
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
|
||||
f"{self.termination_b} has {self.termination_b.positions}. "
|
||||
f"Both terminations must have the same number of positions (if greater than one)."
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
|
||||
if hasattr(self.termination_a, 'device'):
|
||||
self._termination_a_device = self.termination_a.device
|
||||
if hasattr(self.termination_b, 'device'):
|
||||
self._termination_b_device = self.termination_b.device
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
self._pk = self.pk
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||
self.termination_a_id,
|
||||
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
||||
self.termination_b_id,
|
||||
self.get_type_display(),
|
||||
self.get_status_display(),
|
||||
self.label,
|
||||
self.color,
|
||||
self.length,
|
||||
self.length_unit,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return CableStatusChoices.CSS_CLASSES.get(self.status)
|
||||
|
||||
def get_compatible_types(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
|
||||
class CablePath(models.Model):
|
||||
"""
|
||||
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
|
||||
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
|
||||
not terminate on a PathEndpoint).
|
||||
|
||||
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
|
||||
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
|
||||
topology:
|
||||
|
||||
1 2 3
|
||||
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
|
||||
|
||||
This path would be expressed as:
|
||||
|
||||
CablePath(
|
||||
origin = Interface A
|
||||
destination = Interface B
|
||||
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
|
||||
)
|
||||
|
||||
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
|
||||
"connected".
|
||||
"""
|
||||
origin_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+'
|
||||
)
|
||||
origin_id = models.PositiveIntegerField()
|
||||
origin = GenericForeignKey(
|
||||
ct_field='origin_type',
|
||||
fk_field='origin_id'
|
||||
)
|
||||
destination_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
destination_id = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
destination = GenericForeignKey(
|
||||
ct_field='destination_type',
|
||||
fk_field='destination_id'
|
||||
)
|
||||
path = PathField()
|
||||
is_active = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
is_split = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('origin_type', 'origin_id')
|
||||
|
||||
def __str__(self):
|
||||
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
|
||||
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Record a direct reference to this CablePath on its originating object
|
||||
model = self.origin._meta.model
|
||||
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
|
||||
|
||||
@property
|
||||
def segment_count(self):
|
||||
total_length = 1 + len(self.path) + (1 if self.destination else 0)
|
||||
return int(total_length / 3)
|
||||
|
||||
@classmethod
|
||||
def from_origin(cls, origin):
|
||||
"""
|
||||
Create a new CablePath instance as traced from the given path origin.
|
||||
"""
|
||||
if origin is None or origin.cable is None:
|
||||
return None
|
||||
|
||||
destination = None
|
||||
path = []
|
||||
position_stack = []
|
||||
is_active = True
|
||||
is_split = False
|
||||
|
||||
node = origin
|
||||
while node.cable is not None:
|
||||
if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
|
||||
is_active = False
|
||||
|
||||
# Follow the cable to its far-end termination
|
||||
path.append(object_to_path_node(node.cable))
|
||||
peer_termination = node.get_cable_peer()
|
||||
|
||||
# Follow a FrontPort to its corresponding RearPort
|
||||
if isinstance(peer_termination, FrontPort):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
node = peer_termination.rear_port
|
||||
if node.positions > 1:
|
||||
position_stack.append(peer_termination.rear_port_position)
|
||||
path.append(object_to_path_node(node))
|
||||
|
||||
# Follow a RearPort to its corresponding FrontPort
|
||||
elif isinstance(peer_termination, RearPort):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
if peer_termination.positions == 1:
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1)
|
||||
path.append(object_to_path_node(node))
|
||||
elif position_stack:
|
||||
position = position_stack.pop()
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
|
||||
path.append(object_to_path_node(node))
|
||||
else:
|
||||
# No position indicated: path has split, so we stop at the RearPort
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
destination = peer_termination
|
||||
break
|
||||
|
||||
if destination is None:
|
||||
is_active = False
|
||||
|
||||
return cls(
|
||||
origin=origin,
|
||||
destination=destination,
|
||||
path=path,
|
||||
is_active=is_active,
|
||||
is_split=is_split
|
||||
)
|
||||
|
||||
def get_path(self):
|
||||
"""
|
||||
Return the path as a list of prefetched objects.
|
||||
"""
|
||||
# Compile a list of IDs to prefetch for each type of model in the path
|
||||
to_prefetch = defaultdict(list)
|
||||
for node in self.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
to_prefetch[ct_id].append(object_id)
|
||||
|
||||
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
|
||||
prefetched = {}
|
||||
for ct_id, object_ids in to_prefetch.items():
|
||||
model_class = ContentType.objects.get_for_id(ct_id).model_class()
|
||||
queryset = model_class.objects.filter(pk__in=object_ids)
|
||||
if hasattr(model_class, 'device'):
|
||||
queryset = queryset.prefetch_related('device')
|
||||
prefetched[ct_id] = {
|
||||
obj.id: obj for obj in queryset
|
||||
}
|
||||
|
||||
# Replicate the path using the prefetched objects.
|
||||
path = []
|
||||
for node in self.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
path.append(prefetched[ct_id][object_id])
|
||||
|
||||
return path
|
||||
|
||||
def get_total_length(self):
|
||||
"""
|
||||
Return the sum of the length of each cable in the path.
|
||||
"""
|
||||
cable_ids = [
|
||||
# Starting from the first element, every third element in the path should be a Cable
|
||||
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
|
||||
]
|
||||
return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total']
|
||||
|
||||
def get_split_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
rearport = path_node_to_object(self.path[-1])
|
||||
return FrontPort.objects.filter(rear_port=rearport)
|
||||
@@ -264,10 +264,7 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -318,10 +315,7 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.exceptions import CableTraceSplit
|
||||
from dcim.fields import MACAddressField
|
||||
from extras.models import ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
@@ -30,7 +30,6 @@ __all__ = (
|
||||
'FrontPort',
|
||||
'Interface',
|
||||
'InventoryItem',
|
||||
'PathEndpoint',
|
||||
'PowerOutlet',
|
||||
'PowerPort',
|
||||
'RearPort',
|
||||
@@ -38,9 +37,6 @@ __all__ = (
|
||||
|
||||
|
||||
class ComponentModel(models.Model):
|
||||
"""
|
||||
An abstract model inherited by any model which has a parent Device.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
@@ -95,14 +91,6 @@ class ComponentModel(models.Model):
|
||||
|
||||
|
||||
class CableTermination(models.Model):
|
||||
"""
|
||||
An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and
|
||||
CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
|
||||
|
||||
`_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a
|
||||
shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in
|
||||
dcim.signals when a Cable instance is created or deleted, respectively.
|
||||
"""
|
||||
cable = models.ForeignKey(
|
||||
to='dcim.Cable',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -110,21 +98,6 @@ class CableTermination(models.Model):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_cable_peer_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_cable_peer_id = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_cable_peer = GenericForeignKey(
|
||||
ct_field='_cable_peer_type',
|
||||
fk_field='_cable_peer_id'
|
||||
)
|
||||
|
||||
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
|
||||
_cabled_as_a = GenericRelation(
|
||||
@@ -138,60 +111,141 @@ class CableTermination(models.Model):
|
||||
object_id_field='termination_b_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_cable_peer(self):
|
||||
return self._cable_peer
|
||||
|
||||
|
||||
class PathEndpoint(models.Model):
|
||||
"""
|
||||
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
|
||||
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination.
|
||||
|
||||
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
|
||||
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
|
||||
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
|
||||
|
||||
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def trace(self):
|
||||
if self._path is None:
|
||||
return []
|
||||
|
||||
# Construct the complete path
|
||||
path = [self, *self._path.get_path()]
|
||||
while (len(path) + 1) % 3:
|
||||
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
|
||||
path.append(None)
|
||||
path.append(self._path.destination)
|
||||
|
||||
# Return the path as a list of three-tuples (A termination, cable, B termination)
|
||||
return list(zip(*[iter(path)] * 3))
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
Caching accessor for the attached CablePath's destination (if any)
|
||||
Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
|
||||
the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
|
||||
along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
|
||||
to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
|
||||
a FrontPort without traversing a RearPort again.
|
||||
|
||||
The path is a list representing a complete cable path, with each individual segment represented as a
|
||||
three-tuple:
|
||||
|
||||
[
|
||||
(termination A, cable, termination B),
|
||||
(termination C, cable, termination D),
|
||||
(termination E, cable, termination F)
|
||||
]
|
||||
"""
|
||||
if not hasattr(self, '_connected_endpoint'):
|
||||
self._connected_endpoint = self._path.destination if self._path else None
|
||||
return self._connected_endpoint
|
||||
endpoint = self
|
||||
path = []
|
||||
position_stack = []
|
||||
|
||||
def get_peer_port(termination):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
# Map a front port to its corresponding rear port
|
||||
if isinstance(termination, FrontPort):
|
||||
# Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
|
||||
peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
|
||||
|
||||
# Don't use the stack for RearPorts with a single position. Only remember the position at
|
||||
# many-to-one points so we can select the correct FrontPort when we reach the corresponding
|
||||
# one-to-many point.
|
||||
if peer_port.positions > 1:
|
||||
position_stack.append(termination)
|
||||
|
||||
return peer_port
|
||||
|
||||
# Map a rear port/position to its corresponding front port
|
||||
elif isinstance(termination, RearPort):
|
||||
if termination.positions > 1:
|
||||
# Can't map to a FrontPort without a position if there are multiple options
|
||||
if not position_stack:
|
||||
raise CableTraceSplit(termination)
|
||||
|
||||
front_port = position_stack.pop()
|
||||
position = front_port.rear_port_position
|
||||
|
||||
# Validate the position
|
||||
if position not in range(1, termination.positions + 1):
|
||||
raise Exception("Invalid position for {} ({} positions): {})".format(
|
||||
termination, termination.positions, position
|
||||
))
|
||||
else:
|
||||
# Don't use the stack for RearPorts with a single position. The only possible position is 1.
|
||||
position = 1
|
||||
|
||||
try:
|
||||
peer_port = FrontPort.objects.get(
|
||||
rear_port=termination,
|
||||
rear_port_position=position,
|
||||
)
|
||||
return peer_port
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
# Follow a circuit to its other termination
|
||||
elif isinstance(termination, CircuitTermination):
|
||||
peer_termination = termination.get_peer_termination()
|
||||
if peer_termination is None:
|
||||
return None
|
||||
return peer_termination
|
||||
|
||||
# Termination is not a pass-through port
|
||||
else:
|
||||
return None
|
||||
|
||||
logger = logging.getLogger('netbox.dcim.cable.trace')
|
||||
logger.debug("Tracing cable from {} {}".format(self.parent, self))
|
||||
|
||||
while endpoint is not None:
|
||||
|
||||
# No cable connected; nothing to trace
|
||||
if not endpoint.cable:
|
||||
path.append((endpoint, None, None))
|
||||
logger.debug("No cable connected")
|
||||
return path, None, position_stack
|
||||
|
||||
# Check for loops
|
||||
if endpoint.cable in [segment[1] for segment in path]:
|
||||
logger.debug("Loop detected!")
|
||||
return path, None, position_stack
|
||||
|
||||
# Record the current segment in the path
|
||||
far_end = endpoint.get_cable_peer()
|
||||
path.append((endpoint, endpoint.cable, far_end))
|
||||
logger.debug("{}[{}] --- Cable {} ---> {}[{}]".format(
|
||||
endpoint.parent, endpoint, endpoint.cable.pk, far_end.parent, far_end
|
||||
))
|
||||
|
||||
# Get the peer port of the far end termination
|
||||
try:
|
||||
endpoint = get_peer_port(far_end)
|
||||
except CableTraceSplit as e:
|
||||
return path, e.termination.frontports.all(), position_stack
|
||||
|
||||
if endpoint is None:
|
||||
return path, None, position_stack
|
||||
|
||||
def get_cable_peer(self):
|
||||
if self.cable is None:
|
||||
return None
|
||||
if self._cabled_as_a.exists():
|
||||
return self.cable.termination_b
|
||||
if self._cabled_as_b.exists():
|
||||
return self.cable.termination_a
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Return all endpoints of paths which traverse this object.
|
||||
"""
|
||||
endpoints = []
|
||||
|
||||
# Get the far end of the last path segment
|
||||
path, split_ends, position_stack = self.trace()
|
||||
endpoint = path[-1][2]
|
||||
if split_ends is not None:
|
||||
for termination in split_ends:
|
||||
endpoints.extend(termination.get_path_endpoints())
|
||||
elif endpoint is not None:
|
||||
endpoints.append(endpoint)
|
||||
|
||||
return endpoints
|
||||
|
||||
|
||||
#
|
||||
@@ -199,7 +253,7 @@ class PathEndpoint(models.Model):
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
class ConsolePort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
"""
|
||||
@@ -209,6 +263,18 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
connected_endpoint = models.OneToOneField(
|
||||
to='dcim.ConsoleServerPort',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='connected_endpoint',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
@@ -235,7 +301,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
"""
|
||||
@@ -245,6 +311,11 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
@@ -271,7 +342,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
class PowerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
"""
|
||||
@@ -293,6 +364,25 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Allocated power draw (watts)"
|
||||
)
|
||||
_connected_poweroutlet = models.OneToOneField(
|
||||
to='dcim.PowerOutlet',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='connected_endpoint',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_connected_powerfeed = models.OneToOneField(
|
||||
to='dcim.PowerFeed',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
|
||||
@@ -315,13 +405,42 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
self.description,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for
|
||||
ObjectDoesNotExist in case the referenced object has been deleted from the database.
|
||||
"""
|
||||
try:
|
||||
if self._connected_poweroutlet:
|
||||
return self._connected_poweroutlet
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
try:
|
||||
if self._connected_powerfeed:
|
||||
return self._connected_powerfeed
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
raise ValidationError({
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
@connected_endpoint.setter
|
||||
def connected_endpoint(self, value):
|
||||
# TODO: Fix circular import
|
||||
from . import PowerFeed
|
||||
|
||||
if value is None:
|
||||
self._connected_poweroutlet = None
|
||||
self._connected_powerfeed = None
|
||||
elif isinstance(value, PowerOutlet):
|
||||
self._connected_poweroutlet = value
|
||||
self._connected_powerfeed = None
|
||||
elif isinstance(value, PowerFeed):
|
||||
self._connected_poweroutlet = None
|
||||
self._connected_powerfeed = value
|
||||
else:
|
||||
raise ValueError(
|
||||
"Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
|
||||
)
|
||||
|
||||
def get_power_draw(self):
|
||||
"""
|
||||
@@ -329,12 +448,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually
|
||||
if self.allocated_draw is None and self.maximum_draw is None:
|
||||
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(
|
||||
_cable_peer_type=poweroutlet_ct,
|
||||
_cable_peer_id__in=outlet_ids
|
||||
).aggregate(
|
||||
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
|
||||
maximum_draw_total=Sum('maximum_draw'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
@@ -346,13 +461,10 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
}
|
||||
|
||||
# Calculate per-leg aggregates for three-phase feeds
|
||||
if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
|
||||
if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
|
||||
for leg, leg_name in PowerOutletFeedLegChoices:
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(
|
||||
_cable_peer_type=poweroutlet_ct,
|
||||
_cable_peer_id__in=outlet_ids
|
||||
).aggregate(
|
||||
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
|
||||
maximum_draw_total=Sum('maximum_draw'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
@@ -379,7 +491,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
|
||||
class PowerOutlet(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
"""
|
||||
@@ -402,6 +514,11 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
|
||||
blank=True,
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
|
||||
@@ -465,8 +582,8 @@ class BaseInterface(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
@extras_features('graphs', 'export_templates', 'webhooks')
|
||||
class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
"""
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
"""
|
||||
@@ -477,6 +594,25 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
_connected_interface = models.OneToOneField(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_connected_circuittermination = models.OneToOneField(
|
||||
to='circuits.CircuitTermination',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
lag = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -592,6 +728,42 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to
|
||||
check for ObjectDoesNotExist in case the referenced object has been deleted from the database.
|
||||
"""
|
||||
try:
|
||||
if self._connected_interface:
|
||||
return self._connected_interface
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
try:
|
||||
if self._connected_circuittermination:
|
||||
return self._connected_circuittermination
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
@connected_endpoint.setter
|
||||
def connected_endpoint(self, value):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
if value is None:
|
||||
self._connected_interface = None
|
||||
self._connected_circuittermination = None
|
||||
elif isinstance(value, Interface):
|
||||
self._connected_interface = value
|
||||
self._connected_circuittermination = None
|
||||
elif isinstance(value, CircuitTermination):
|
||||
self._connected_interface = None
|
||||
self._connected_circuittermination = value
|
||||
else:
|
||||
raise ValueError(
|
||||
"Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
|
||||
)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device
|
||||
@@ -637,10 +809,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -671,16 +840,17 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
raise ValidationError({
|
||||
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
|
||||
})
|
||||
raise ValidationError(
|
||||
"Rear port ({}) must belong to the same device".format(self.rear_port)
|
||||
)
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError({
|
||||
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
|
||||
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
|
||||
})
|
||||
raise ValidationError(
|
||||
"Invalid rear port position ({}); rear port {} has only {} positions".format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@@ -694,10 +864,7 @@ class RearPort(CableTermination, ComponentModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -710,16 +877,6 @@ class RearPort(CableTermination, ComponentModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that positions count is greater than or equal to the number of associated FrontPorts
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": f"The number of positions cannot be less than the number of mapped front ports "
|
||||
f"({frontport_count})"
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
@@ -795,18 +952,17 @@ class DeviceBay(ComponentModel):
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class InventoryItem(MPTTModel, ComponentModel):
|
||||
class InventoryItem(ComponentModel):
|
||||
"""
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
InventoryItems are used only for inventory purposes.
|
||||
"""
|
||||
parent = TreeForeignKey(
|
||||
parent = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='child_items',
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
null=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
to='dcim.Manufacturer',
|
||||
@@ -841,8 +997,6 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = [
|
||||
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
]
|
||||
|
||||
@@ -2,8 +2,9 @@ from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@@ -14,15 +15,16 @@ from taggit.managers import TaggableManager
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from .device_components import *
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
'Device',
|
||||
'DeviceRole',
|
||||
'DeviceType',
|
||||
@@ -42,11 +44,10 @@ class Manufacturer(ChangeLoggedModel):
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@@ -97,11 +98,9 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
related_name='device_types'
|
||||
)
|
||||
model = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100
|
||||
max_length=50
|
||||
)
|
||||
slug = models.SlugField()
|
||||
part_number = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
@@ -135,6 +134,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@@ -254,7 +258,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
|
||||
# room to expand within their racks. This validation will impose a very high performance penalty when there are
|
||||
@@ -345,11 +348,10 @@ class DeviceRole(ChangeLoggedModel):
|
||||
virtual machines as well.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
color = ColorField(
|
||||
@@ -396,8 +398,8 @@ class Platform(ChangeLoggedModel):
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
unique=True,
|
||||
max_length=100
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
to='dcim.Manufacturer',
|
||||
@@ -448,7 +450,7 @@ class Platform(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
@@ -582,18 +584,17 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
secrets = GenericRelation(
|
||||
to='secrets.Secret',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='device'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
@@ -603,6 +604,16 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
DeviceStatusChoices.STATUS_OFFLINE: 'warning',
|
||||
DeviceStatusChoices.STATUS_ACTIVE: 'success',
|
||||
DeviceStatusChoices.STATUS_PLANNED: 'info',
|
||||
DeviceStatusChoices.STATUS_STAGED: 'primary',
|
||||
DeviceStatusChoices.STATUS_FAILED: 'danger',
|
||||
DeviceStatusChoices.STATUS_INVENTORY: 'default',
|
||||
DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name', 'pk') # Name may be null
|
||||
unique_together = (
|
||||
@@ -635,6 +646,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
super().validate_unique(exclude)
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate site/rack combination
|
||||
@@ -855,7 +867,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
"""
|
||||
Return a QuerySet or PK list matching all Cables connected to a component of this Device.
|
||||
"""
|
||||
from .cables import Cable
|
||||
cable_pks = []
|
||||
for component_model in [
|
||||
ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
|
||||
@@ -874,15 +885,301 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
return Device.objects.filter(parent_bay__device=self.pk)
|
||||
|
||||
def get_status_class(self):
|
||||
return DeviceStatusChoices.CSS_CLASSES.get(self.status)
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class Cable(ChangeLoggedModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
"""
|
||||
termination_a_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_a_id = models.PositiveIntegerField()
|
||||
termination_a = GenericForeignKey(
|
||||
ct_field='termination_a_type',
|
||||
fk_field='termination_a_id'
|
||||
)
|
||||
termination_b_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_b_id = models.PositiveIntegerField()
|
||||
termination_b = GenericForeignKey(
|
||||
ct_field='termination_b_type',
|
||||
fk_field='termination_b_id'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableTypeChoices,
|
||||
blank=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableStatusChoices,
|
||||
default=CableStatusChoices.STATUS_CONNECTED
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
color = ColorField(
|
||||
blank=True
|
||||
)
|
||||
length = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
length_unit = models.CharField(
|
||||
max_length=50,
|
||||
choices=CableLengthUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
# Stores the normalized length (in meters) for database ordering
|
||||
_abs_length = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
|
||||
# their associated Devices.
|
||||
_termination_a_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_termination_b_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
|
||||
'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
CableStatusChoices.STATUS_CONNECTED: 'success',
|
||||
CableStatusChoices.STATUS_PLANNED: 'info',
|
||||
CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
('termination_a_type', 'termination_a_id'),
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db, field_names, values):
|
||||
"""
|
||||
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
|
||||
"""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
|
||||
instance._orig_termination_a_type_id = instance.termination_a_type_id
|
||||
instance._orig_termination_a_id = instance.termination_a_id
|
||||
instance._orig_termination_b_type_id = instance.termination_b_type_id
|
||||
instance._orig_termination_b_id = instance.termination_b_id
|
||||
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
raise ValidationError('Termination A type has not been specified')
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
if not hasattr(self, 'termination_b_type'):
|
||||
raise ValidationError('Termination B type has not been specified')
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
# If editing an existing Cable instance, check that neither termination has been modified.
|
||||
if self.pk:
|
||||
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
|
||||
if (
|
||||
self.termination_a_type_id != self._orig_termination_a_type_id or
|
||||
self.termination_a_id != self._orig_termination_a_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_a': err_msg
|
||||
})
|
||||
if (
|
||||
self.termination_b_type_id != self._orig_termination_b_type_id or
|
||||
self.termination_b_id != self._orig_termination_b_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_b': err_msg
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# Validate interface types
|
||||
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_a.get_type_display()
|
||||
)
|
||||
})
|
||||
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_b.get_type_display()
|
||||
)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError(
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# Check that a RearPort with multiple positions isn't connected to an endpoint
|
||||
# or a RearPort with a different number of positions.
|
||||
for term_a, term_b in [
|
||||
(self.termination_a, self.termination_b),
|
||||
(self.termination_b, self.termination_a)
|
||||
]:
|
||||
if isinstance(term_a, RearPort) and term_a.positions > 1:
|
||||
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
|
||||
raise ValidationError(
|
||||
"Rear ports with multiple positions may only be connected to other pass-through ports"
|
||||
)
|
||||
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
|
||||
raise ValidationError(
|
||||
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
|
||||
f"{term_b} of {term_b.device} has {term_b.positions}. "
|
||||
f"Both terminations must have the same number of positions."
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
|
||||
if hasattr(self.termination_a, 'device'):
|
||||
self._termination_a_device = self.termination_a.device
|
||||
if hasattr(self.termination_b, 'device'):
|
||||
self._termination_b_device = self.termination_b.device
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
self._pk = self.pk
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||
self.termination_a_id,
|
||||
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
||||
self.termination_b_id,
|
||||
self.get_type_display(),
|
||||
self.get_status_display(),
|
||||
self.label,
|
||||
self.color,
|
||||
self.length,
|
||||
self.length_unit,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
def get_compatible_types(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class VirtualChassis(ChangeLoggedModel, CustomFieldModel):
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class VirtualChassis(ChangeLoggedModel):
|
||||
"""
|
||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||
"""
|
||||
@@ -917,7 +1214,6 @@ class VirtualChassis(ChangeLoggedModel, CustomFieldModel):
|
||||
return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||
# VirtualChassis.)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -10,7 +11,7 @@ from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import CableTermination, PathEndpoint
|
||||
from .device_components import CableTermination
|
||||
|
||||
__all__ = (
|
||||
'PowerFeed',
|
||||
@@ -22,8 +23,8 @@ __all__ = (
|
||||
# Power
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel, CustomFieldModel):
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
@@ -38,7 +39,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel):
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
max_length=50
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -64,7 +65,6 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# RackGroup must belong to assigned Site
|
||||
if self.rack_group and self.rack_group.site != self.site:
|
||||
@@ -74,7 +74,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel):
|
||||
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
@@ -89,8 +89,20 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connected_endpoint = models.OneToOneField(
|
||||
to='dcim.PowerPort',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
max_length=50
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
@@ -132,6 +144,11 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@@ -145,6 +162,18 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo
|
||||
'available_power',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
|
||||
PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
|
||||
PowerFeedStatusChoices.STATUS_PLANNED: 'info',
|
||||
PowerFeedStatusChoices.STATUS_FAILED: 'danger',
|
||||
}
|
||||
|
||||
TYPE_CLASS_MAP = {
|
||||
PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
|
||||
PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
unique_together = ['power_panel', 'name']
|
||||
@@ -173,7 +202,6 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
@@ -203,7 +231,7 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo
|
||||
return self.power_panel
|
||||
|
||||
def get_type_class(self):
|
||||
return PowerFeedTypeChoices.CSS_CLASSES.get(self.type)
|
||||
return self.TYPE_CLASS_MAP.get(self.type)
|
||||
|
||||
def get_status_class(self):
|
||||
return PowerFeedStatusChoices.CSS_CLASSES.get(self.status)
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -22,8 +22,7 @@ from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.utils import array_to_string, serialize_object
|
||||
from .device_components import PowerOutlet, PowerPort
|
||||
from utilities.utils import serialize_object
|
||||
from .devices import Device
|
||||
from .power import PowerFeed
|
||||
|
||||
@@ -47,11 +46,9 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100
|
||||
max_length=50
|
||||
)
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.CASCADE,
|
||||
@@ -120,11 +117,10 @@ class RackRole(ChangeLoggedModel):
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
color = ColorField(
|
||||
@@ -164,7 +160,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
@@ -265,6 +261,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@@ -281,6 +282,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
'outer_depth', 'outer_unit',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
RackStatusChoices.STATUS_RESERVED: 'warning',
|
||||
RackStatusChoices.STATUS_AVAILABLE: 'success',
|
||||
RackStatusChoices.STATUS_PLANNED: 'info',
|
||||
RackStatusChoices.STATUS_ACTIVE: 'primary',
|
||||
RackStatusChoices.STATUS_DEPRECATED: 'danger',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
|
||||
unique_together = (
|
||||
@@ -296,7 +305,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
@@ -377,7 +385,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
return self.name
|
||||
|
||||
def get_status_class(self):
|
||||
return RackStatusChoices.CSS_CLASSES.get(self.status)
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
|
||||
"""
|
||||
@@ -542,26 +550,24 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Determine the utilization rate of power in the rack and return it as a percentage.
|
||||
"""
|
||||
powerfeeds = PowerFeed.objects.filter(rack=self)
|
||||
available_power_total = sum(pf.available_power for pf in powerfeeds)
|
||||
if not available_power_total:
|
||||
return 0
|
||||
|
||||
pf_powerports = PowerPort.objects.filter(
|
||||
_cable_peer_type=ContentType.objects.get_for_model(PowerFeed),
|
||||
_cable_peer_id__in=powerfeeds.values_list('id', flat=True)
|
||||
power_stats = PowerFeed.objects.filter(
|
||||
rack=self
|
||||
).annotate(
|
||||
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
|
||||
).values(
|
||||
'allocated_draw_total',
|
||||
'available_power'
|
||||
)
|
||||
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
|
||||
allocated_draw_total = PowerPort.objects.filter(
|
||||
_cable_peer_type=ContentType.objects.get_for_model(PowerOutlet),
|
||||
_cable_peer_id__in=poweroutlets.values_list('id', flat=True)
|
||||
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
|
||||
|
||||
return int(allocated_draw_total / available_power_total * 100)
|
||||
if power_stats:
|
||||
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
|
||||
available_power_total = sum(x['available_power'] for x in power_stats)
|
||||
return int(allocated_draw_total / available_power_total * 100) or 0
|
||||
return 0
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class RackReservation(ChangeLoggedModel, CustomFieldModel):
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class RackReservation(ChangeLoggedModel):
|
||||
"""
|
||||
One or more reserved units within a Rack.
|
||||
"""
|
||||
@@ -603,7 +609,6 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if hasattr(self, 'rack') and self.units:
|
||||
|
||||
@@ -642,4 +647,9 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def unit_list(self):
|
||||
return array_to_string(self.units)
|
||||
"""
|
||||
Express the assigned units as a string of summarized ranges. For example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||
"""
|
||||
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
|
||||
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
|
||||
|
||||
@@ -39,11 +39,10 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
db_index=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@@ -92,14 +91,14 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
@@ -108,7 +107,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
blank=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
status = models.CharField(
|
||||
@@ -185,6 +183,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@@ -201,6 +204,14 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
SiteStatusChoices.STATUS_PLANNED: 'info',
|
||||
SiteStatusChoices.STATUS_STAGING: 'primary',
|
||||
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
||||
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
SiteStatusChoices.STATUS_RETIRED: 'danger',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name',)
|
||||
|
||||
@@ -232,4 +243,4 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return SiteStatusChoices.CSS_CLASSES.get(self.status)
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableStatusChoices
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
|
||||
|
||||
|
||||
def create_cablepath(node):
|
||||
"""
|
||||
Create CablePaths for all paths originating from the specified node.
|
||||
"""
|
||||
cp = CablePath.from_origin(node)
|
||||
if cp:
|
||||
try:
|
||||
cp.save()
|
||||
except Exception as e:
|
||||
print(node, node.pk)
|
||||
raise e
|
||||
|
||||
|
||||
def rebuild_paths(obj):
|
||||
"""
|
||||
Rebuild all CablePaths which traverse the specified node
|
||||
"""
|
||||
cable_paths = CablePath.objects.filter(path__contains=obj)
|
||||
|
||||
with transaction.atomic():
|
||||
for cp in cable_paths:
|
||||
cp.delete()
|
||||
create_cablepath(cp.origin)
|
||||
from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis
|
||||
|
||||
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
@@ -59,73 +32,71 @@ def clear_virtualchassis_members(instance, **kwargs):
|
||||
|
||||
|
||||
@receiver(post_save, sender=Cable)
|
||||
def update_connected_endpoints(instance, created, raw=False, **kwargs):
|
||||
def update_connected_endpoints(instance, **kwargs):
|
||||
"""
|
||||
When a Cable is saved, check for and update its two connected endpoints
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
if raw:
|
||||
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
|
||||
return
|
||||
|
||||
# Cache the Cable on its two termination points
|
||||
if instance.termination_a.cable != instance:
|
||||
logger.debug(f"Updating termination A for cable {instance}")
|
||||
logger.debug("Updating termination A for cable {}".format(instance))
|
||||
instance.termination_a.cable = instance
|
||||
instance.termination_a._cable_peer = instance.termination_b
|
||||
instance.termination_a.save()
|
||||
if instance.termination_b.cable != instance:
|
||||
logger.debug(f"Updating termination B for cable {instance}")
|
||||
logger.debug("Updating termination B for cable {}".format(instance))
|
||||
instance.termination_b.cable = instance
|
||||
instance.termination_b._cable_peer = instance.termination_a
|
||||
instance.termination_b.save()
|
||||
|
||||
# Create/update cable paths
|
||||
if created:
|
||||
for termination in (instance.termination_a, instance.termination_b):
|
||||
if isinstance(termination, PathEndpoint):
|
||||
create_cablepath(termination)
|
||||
else:
|
||||
rebuild_paths(termination)
|
||||
elif instance.status != instance._orig_status:
|
||||
# We currently don't support modifying either termination of an existing Cable. (This
|
||||
# may change in the future.) However, we do need to capture status changes and update
|
||||
# any CablePaths accordingly.
|
||||
if instance.status != CableStatusChoices.STATUS_CONNECTED:
|
||||
CablePath.objects.filter(path__contains=instance).update(is_active=False)
|
||||
else:
|
||||
rebuild_paths(instance)
|
||||
# Update any endpoints for this Cable.
|
||||
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
|
||||
for endpoint in endpoints:
|
||||
path, split_ends, position_stack = endpoint.trace()
|
||||
# Determine overall path status (connected or planned)
|
||||
path_status = True
|
||||
for segment in path:
|
||||
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = False
|
||||
break
|
||||
|
||||
endpoint_a = path[0][0]
|
||||
endpoint_b = path[-1][2] if not split_ends and not position_stack else None
|
||||
|
||||
# Patch panel ports are not connected endpoints, all other cable terminations are
|
||||
if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
|
||||
isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
|
||||
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
|
||||
endpoint_a.connected_endpoint = endpoint_b
|
||||
endpoint_a.connection_status = path_status
|
||||
endpoint_a.save()
|
||||
endpoint_b.connected_endpoint = endpoint_a
|
||||
endpoint_b.connection_status = path_status
|
||||
endpoint_b.save()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Cable)
|
||||
@receiver(pre_delete, sender=Cable)
|
||||
def nullify_connected_endpoints(instance, **kwargs):
|
||||
"""
|
||||
When a Cable is deleted, check for and update its two connected endpoints
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
|
||||
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
|
||||
|
||||
# Disassociate the Cable from its termination points
|
||||
if instance.termination_a is not None:
|
||||
logger.debug(f"Nullifying termination A for cable {instance}")
|
||||
logger.debug("Nullifying termination A for cable {}".format(instance))
|
||||
instance.termination_a.cable = None
|
||||
instance.termination_a._cable_peer = None
|
||||
instance.termination_a.save()
|
||||
if instance.termination_b is not None:
|
||||
logger.debug(f"Nullifying termination B for cable {instance}")
|
||||
logger.debug("Nullifying termination B for cable {}".format(instance))
|
||||
instance.termination_b.cable = None
|
||||
instance.termination_b._cable_peer = None
|
||||
instance.termination_b.save()
|
||||
|
||||
# Delete and retrace any dependent cable paths
|
||||
for cablepath in CablePath.objects.filter(path__contains=instance):
|
||||
cp = CablePath.from_origin(cablepath.origin)
|
||||
if cp:
|
||||
CablePath.objects.filter(pk=cablepath.pk).update(
|
||||
path=cp.path,
|
||||
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
|
||||
destination_id=cp.destination.pk if cp.destination else None,
|
||||
is_active=cp.is_active,
|
||||
is_split=cp.is_split
|
||||
)
|
||||
else:
|
||||
cablepath.delete()
|
||||
# If this Cable was part of any complete end-to-end paths, tear them down.
|
||||
for endpoint in endpoints:
|
||||
logger.debug(f"Removing path information for {endpoint}")
|
||||
if hasattr(endpoint, 'connected_endpoint'):
|
||||
endpoint.connected_endpoint = None
|
||||
endpoint.connection_status = None
|
||||
endpoint.save()
|
||||
|
||||
1070
netbox/dcim/tables.py
Normal file
1070
netbox/dcim/tables.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, BooleanColumn
|
||||
from dcim.models import ConsolePort, Interface, PowerPort
|
||||
from .cables import *
|
||||
from .devices import *
|
||||
from .devicetypes import *
|
||||
from .power import *
|
||||
from .racks import *
|
||||
from .sites import *
|
||||
|
||||
|
||||
#
|
||||
# Device connections
|
||||
#
|
||||
|
||||
class ConsoleConnectionTable(BaseTable):
|
||||
console_server = tables.Column(
|
||||
accessor=Accessor('_path__destination__device'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Console Server'
|
||||
)
|
||||
console_server_port = tables.Column(
|
||||
accessor=Accessor('_path__destination'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Port'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Console Port'
|
||||
)
|
||||
reachable = BooleanColumn(
|
||||
accessor=Accessor('_path__is_active'),
|
||||
verbose_name='Reachable'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
|
||||
|
||||
|
||||
class PowerConnectionTable(BaseTable):
|
||||
pdu = tables.Column(
|
||||
accessor=Accessor('_path__destination__device'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='PDU'
|
||||
)
|
||||
outlet = tables.Column(
|
||||
accessor=Accessor('_path__destination'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Outlet'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Power Port'
|
||||
)
|
||||
reachable = BooleanColumn(
|
||||
accessor=Accessor('_path__is_active'),
|
||||
verbose_name='Reachable'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
|
||||
|
||||
|
||||
class InterfaceConnectionTable(BaseTable):
|
||||
device_a = tables.Column(
|
||||
accessor=Accessor('device'),
|
||||
linkify=True,
|
||||
verbose_name='Device A'
|
||||
)
|
||||
interface_a = tables.Column(
|
||||
accessor=Accessor('name'),
|
||||
linkify=True,
|
||||
verbose_name='Interface A'
|
||||
)
|
||||
device_b = tables.Column(
|
||||
accessor=Accessor('_path__destination__device'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Device B'
|
||||
)
|
||||
interface_b = tables.Column(
|
||||
accessor=Accessor('_path__destination'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Interface B'
|
||||
)
|
||||
reachable = BooleanColumn(
|
||||
accessor=Accessor('_path__is_active'),
|
||||
verbose_name='Reachable'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
|
||||
@@ -1,64 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Cable
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
|
||||
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
|
||||
|
||||
__all__ = (
|
||||
'CableTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
id = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
)
|
||||
termination_a_parent = tables.TemplateColumn(
|
||||
template_code=CABLE_TERMINATION_PARENT,
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
verbose_name='Side A'
|
||||
)
|
||||
termination_a = tables.LinkColumn(
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
verbose_name='Termination A'
|
||||
)
|
||||
termination_b_parent = tables.TemplateColumn(
|
||||
template_code=CABLE_TERMINATION_PARENT,
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
verbose_name='Side B'
|
||||
)
|
||||
termination_b = tables.LinkColumn(
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
length = tables.TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by='_abs_length'
|
||||
)
|
||||
color = ColorColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:cable_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'status', 'type', 'color', 'length', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'status', 'type',
|
||||
)
|
||||
@@ -1,642 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
|
||||
PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
||||
)
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
TagColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import (
|
||||
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
|
||||
FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
|
||||
POWERPORT_BUTTONS, REARPORT_BUTTONS,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConsolePortTable',
|
||||
'ConsoleServerPortTable',
|
||||
'DeviceBayTable',
|
||||
'DeviceConsolePortTable',
|
||||
'DeviceConsoleServerPortTable',
|
||||
'DeviceDeviceBayTable',
|
||||
'DeviceFrontPortTable',
|
||||
'DeviceImportTable',
|
||||
'DeviceInterfaceTable',
|
||||
'DeviceInventoryItemTable',
|
||||
'DevicePowerPortTable',
|
||||
'DevicePowerOutletTable',
|
||||
'DeviceRearPortTable',
|
||||
'DeviceRoleTable',
|
||||
'DeviceTable',
|
||||
'FrontPortTable',
|
||||
'InterfaceTable',
|
||||
'InventoryItemTable',
|
||||
'PlatformTable',
|
||||
'PowerOutletTable',
|
||||
'PowerPortTable',
|
||||
'RearPortTable',
|
||||
'VirtualChassisTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
|
||||
class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'role': 'slug'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = LinkedCountColumn(
|
||||
viewname='virtualization:virtualmachine_list',
|
||||
url_params={'role': 'slug'},
|
||||
verbose_name='VMs'
|
||||
)
|
||||
color = ColorColumn()
|
||||
vm_role = BooleanColumn()
|
||||
actions = ButtonsColumn(DeviceRole, pk_field='slug')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'platform': 'slug'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = LinkedCountColumn(
|
||||
viewname='virtualization:virtualmachine_list',
|
||||
url_params={'platform': 'slug'},
|
||||
verbose_name='VMs'
|
||||
)
|
||||
actions = ButtonsColumn(Platform, pk_field='slug')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
fields = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
|
||||
'description', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
device_role = ColoredLabelColumn(
|
||||
verbose_name='Role'
|
||||
)
|
||||
device_type = tables.LinkColumn(
|
||||
viewname='dcim:devicetype',
|
||||
args=[Accessor('device_type__pk')],
|
||||
verbose_name='Type',
|
||||
text=lambda record: record.device_type.display_name
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip4 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv4 Address'
|
||||
)
|
||||
primary_ip6 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv6 Address'
|
||||
)
|
||||
cluster = tables.LinkColumn(
|
||||
viewname='virtualization:cluster',
|
||||
args=[Accessor('cluster__pk')]
|
||||
)
|
||||
virtual_chassis = tables.LinkColumn(
|
||||
viewname='dcim:virtualchassis',
|
||||
args=[Accessor('virtual_chassis__pk')]
|
||||
)
|
||||
vc_position = tables.Column(
|
||||
verbose_name='VC Position'
|
||||
)
|
||||
vc_priority = tables.Column(
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = (
|
||||
'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
|
||||
'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
|
||||
)
|
||||
|
||||
|
||||
class DeviceImportTable(BaseTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
device_role = tables.Column(
|
||||
verbose_name='Role'
|
||||
)
|
||||
device_type = tables.Column(
|
||||
verbose_name='Type'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
empty_text = False
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceComponentTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('_name',)
|
||||
)
|
||||
cable = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
order_by = ('device', 'name')
|
||||
|
||||
|
||||
class CableTerminationTable(BaseTable):
|
||||
cable = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
cable_peer = tables.TemplateColumn(
|
||||
accessor='_cable_peer',
|
||||
template_code=CABLETERMINATION,
|
||||
orderable=False,
|
||||
verbose_name='Cable Peer'
|
||||
)
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = tables.TemplateColumn(
|
||||
accessor='_path.destination',
|
||||
template_code=CABLETERMINATION,
|
||||
verbose_name='Connection',
|
||||
orderable=False
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class DeviceConsolePortTable(ConsolePortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=CONSOLEPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleserverport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-console-network-outline"></i> '
|
||||
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=CONSOLESERVERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
class DevicePowerPortTable(PowerPortTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
|
||||
'{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=POWERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer',
|
||||
'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
||||
'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
power_port = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:poweroutlet_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer',
|
||||
'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
|
||||
class DevicePowerOutletTable(PowerOutletTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutlet,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=POWEROUTLET_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class BaseInterfaceTable(BaseTable):
|
||||
enabled = BooleanColumn()
|
||||
ip_addresses = tables.TemplateColumn(
|
||||
template_code=INTERFACE_IPADDRESSES,
|
||||
orderable=False,
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
untagged_vlan = tables.Column(linkify=True)
|
||||
tagged_vlans = tables.TemplateColumn(
|
||||
template_code=INTERFACE_TAGGED_VLANS,
|
||||
orderable=False,
|
||||
verbose_name='Tagged VLANs'
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
class DeviceInterfaceTable(InterfaceTable):
|
||||
name = tables.TemplateColumn(
|
||||
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>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
lag = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='LAG'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=Interface,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=INTERFACE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
|
||||
'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
|
||||
'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class FrontPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
rear_port_position = tables.Column(
|
||||
verbose_name='Position'
|
||||
)
|
||||
rear_port = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:frontport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
|
||||
'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
|
||||
|
||||
|
||||
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>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=FRONTPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
|
||||
'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class RearPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rearport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
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>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=RearPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=REARPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTable(DeviceComponentTable):
|
||||
status = tables.TemplateColumn(
|
||||
template_code=DEVICEBAY_STATUS
|
||||
)
|
||||
installed_device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicebay_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
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>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBay,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=DEVICEBAY_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
manufacturer = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
discovered = BooleanColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
cable = None # Override DeviceComponentTable
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
|
||||
|
||||
class DeviceInventoryItemTable(InventoryItemTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
|
||||
'{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InventoryItem,
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
|
||||
'actions',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
master = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
member_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'virtual_chassis_id': 'pk'},
|
||||
verbose_name='Members'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:virtualchassis_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
|
||||
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
|
||||
@@ -1,200 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
|
||||
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
)
|
||||
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn
|
||||
|
||||
__all__ = (
|
||||
'ConsolePortTemplateTable',
|
||||
'ConsoleServerPortTemplateTable',
|
||||
'DeviceBayTemplateTable',
|
||||
'DeviceTypeTable',
|
||||
'FrontPortTemplateTable',
|
||||
'InterfaceTemplateTable',
|
||||
'ManufacturerTable',
|
||||
'PowerOutletTemplateTable',
|
||||
'PowerPortTemplateTable',
|
||||
'RearPortTemplateTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
devicetype_count = tables.Column(
|
||||
verbose_name='Device Types'
|
||||
)
|
||||
inventoryitem_count = tables.Column(
|
||||
verbose_name='Inventory Items'
|
||||
)
|
||||
platform_count = tables.Column(
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
actions = ButtonsColumn(Manufacturer, pk_field='slug')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
model = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
is_full_depth = BooleanColumn(
|
||||
verbose_name='Full Depth'
|
||||
)
|
||||
instance_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'device_type_id': 'pk'},
|
||||
verbose_name='Instances'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicetype_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'instance_count', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ComponentTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
order_by=('_name',)
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleserverports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_powerports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class PowerOutletTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutletTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_poweroutlets'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerOutletTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
mgmt_only = BooleanColumn(
|
||||
verbose_name='Management Only'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InterfaceTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_interfaces'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InterfaceTemplate
|
||||
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
rear_port_position = tables.Column(
|
||||
verbose_name='Position'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_frontports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = FrontPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class RearPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=RearPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_rearports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RearPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBayTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_devicebays'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceBayTemplate
|
||||
fields = ('pk', 'name', 'label', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -1,77 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import PowerFeed, PowerPanel
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
|
||||
from .devices import CableTerminationTable
|
||||
from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
|
||||
|
||||
__all__ = (
|
||||
'PowerFeedTable',
|
||||
'PowerPanelTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site__slug')]
|
||||
)
|
||||
powerfeed_count = LinkedCountColumn(
|
||||
viewname='dcim:powerfeed_list',
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerpanel_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
|
||||
default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
# We're not using PathEndpointTable for PowerFeed because power connections
|
||||
# cannot traverse pass-through ports.
|
||||
class PowerFeedTable(CableTerminationTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
power_panel = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
type = ChoiceFieldColumn()
|
||||
max_utilization = tables.TemplateColumn(
|
||||
template_code="{{ value }}%"
|
||||
)
|
||||
available_power = tables.Column(
|
||||
verbose_name='Available power (VA)'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerfeed_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
'cable_peer',
|
||||
)
|
||||
@@ -1,170 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, RackGroup, RackReservation, RackRole
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn,
|
||||
)
|
||||
from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
'RackDetailTable',
|
||||
'RackGroupTable',
|
||||
'RackReservationTable',
|
||||
'RackRoleTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site__slug')],
|
||||
verbose_name='Site'
|
||||
)
|
||||
rack_count = tables.Column(
|
||||
verbose_name='Racks'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=RackGroup,
|
||||
prepend_template=RACKGROUP_ELEVATIONS
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(RackRole)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackRole
|
||||
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
order_by=('_name',),
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
role = ColoredLabelColumn()
|
||||
u_height = tables.TemplateColumn(
|
||||
template_code="{{ record.u_height }}U",
|
||||
verbose_name='Height'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'group', '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')
|
||||
|
||||
|
||||
class RackDetailTable(RackTable):
|
||||
device_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'rack_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
get_utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
orderable=False,
|
||||
verbose_name='Space'
|
||||
)
|
||||
get_power_utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
|
||||
class Meta(RackTable.Meta):
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'group', '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',
|
||||
'get_utilization', 'get_power_utilization',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
reservation = tables.Column(
|
||||
accessor='pk',
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
accessor=Accessor('rack__site'),
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
unit_list = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Units'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rackreservation_list'
|
||||
)
|
||||
actions = ButtonsColumn(RackReservation)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
|
||||
from .template_code import MPTT_LINK
|
||||
|
||||
__all__ = (
|
||||
'RegionTable',
|
||||
'SiteTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
)
|
||||
site_count = tables.Column(
|
||||
verbose_name='Sites'
|
||||
)
|
||||
actions = ButtonsColumn(Region)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Region
|
||||
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(
|
||||
order_by=('_name',)
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
region = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
|
||||
@@ -1,245 +0,0 @@
|
||||
CABLETERMINATION = """
|
||||
{% if value %}
|
||||
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
{% if value.device %}
|
||||
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
|
||||
{% elif value.circuit %}
|
||||
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
|
||||
{% elif value.power_panel %}
|
||||
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
|
||||
</a>
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
{% if record.installed_device_id %}
|
||||
<span class="label label-{{ record.installed_device.get_status_class }}">
|
||||
{{ record.installed_device.get_status_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="label label-default">Vacant</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
INTERFACE_TAGGED_VLANS = """
|
||||
{% for vlan in record.tagged_vlans.unrestricted %}
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
MPTT_LINK = """
|
||||
{% if record.get_children %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
|
||||
{% else %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}9px">
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
</span>
|
||||
"""
|
||||
|
||||
POWERFEED_CABLE = """
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
"""
|
||||
|
||||
POWERFEED_CABLETERMINATION = """
|
||||
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
RACKGROUP_ELEVATIONS = """
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="mdi mdi-server"></i>
|
||||
</a>
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
#
|
||||
# Device component buttons
|
||||
#
|
||||
|
||||
CONSOLEPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=device.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=device.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
POWERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=device.pk %}">Power Outlet</a></li>
|
||||
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=device.pk %}">Power Feed</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
POWEROUTLET_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=device.pk %}" title="Connect" class="btn btn-success btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
INTERFACE_BUTTONS = """
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=device.pk %}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif record.is_connectable and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=device.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
FRONTPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=device.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
REARPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=device.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEBAY_BUTTONS = """
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
{% if record.installed_device %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove device"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=device.pk %}" class="btn btn-success btn-xs">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
@@ -1,7 +1,10 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import (
|
||||
@@ -11,6 +14,7 @@ from dcim.models import (
|
||||
Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
)
|
||||
from ipam.models import VLAN
|
||||
from extras.models import Graph
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
@@ -79,9 +83,6 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'region-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -94,9 +95,6 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Site
|
||||
brief_fields = ['id', 'name', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -134,13 +132,30 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_site_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Sites.
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=site-1&foo=1')
|
||||
|
||||
|
||||
class RackGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackGroup
|
||||
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -202,9 +217,6 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
'color': 'ffff00',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -220,9 +232,6 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Rack
|
||||
brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -308,9 +317,6 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackReservation
|
||||
brief_fields = ['id', 'units', 'url', 'user']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -373,9 +379,6 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'manufacturer-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -391,9 +394,6 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceType
|
||||
brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'part_number': 'ABC123',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -433,9 +433,6 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -470,9 +467,6 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -507,9 +501,6 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -544,9 +535,6 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutletTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -581,9 +569,6 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InterfaceTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -621,9 +606,6 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -670,21 +652,21 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Front Port Template 4',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[3].pk,
|
||||
'rear_port_position': 1,
|
||||
'position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 5',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[4].pk,
|
||||
'rear_port_position': 1,
|
||||
'position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 6',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[5].pk,
|
||||
'rear_port_position': 1,
|
||||
'position': 1,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -692,9 +674,6 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -732,9 +711,6 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -786,9 +762,6 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
'color': 'ffff00',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -818,9 +791,6 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'platform-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -836,9 +806,6 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Device
|
||||
brief_fields = ['display_name', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'failed',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -935,6 +902,26 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_device_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Devices.
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(Device)
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Device 1&foo=1')
|
||||
|
||||
def test_config_context_included_by_default_in_list_view(self):
|
||||
"""
|
||||
Check that config context data is included by default in the devices list.
|
||||
@@ -976,10 +963,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = ConsoleServerPort
|
||||
|
||||
@classmethod
|
||||
@@ -1015,10 +999,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
|
||||
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = ConsolePort
|
||||
|
||||
@classmethod
|
||||
@@ -1054,10 +1035,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
|
||||
|
||||
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = PowerOutlet
|
||||
|
||||
@classmethod
|
||||
@@ -1093,10 +1071,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
|
||||
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutlet
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = PowerPort
|
||||
|
||||
@classmethod
|
||||
@@ -1132,10 +1107,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
|
||||
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = Interface
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
@@ -1187,13 +1159,30 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_interface_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Devices.
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(Interface)
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
self.add_permissions('dcim.view_interface')
|
||||
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
|
||||
|
||||
|
||||
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
@@ -1246,12 +1235,9 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
|
||||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
@@ -1291,9 +1277,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBay
|
||||
brief_fields = ['device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1353,10 +1336,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InventoryItem
|
||||
brief_fields = ['_depth', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
brief_fields = ['device', 'id', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1366,9 +1346,12 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
|
||||
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
|
||||
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer)
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer)
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer)
|
||||
inventory_items = (
|
||||
InventoryItem(device=device, name='Inventory Item 1', manufacturer=manufacturer),
|
||||
InventoryItem(device=device, name='Inventory Item 2', manufacturer=manufacturer),
|
||||
InventoryItem(device=device, name='Inventory Item 3', manufacturer=manufacturer),
|
||||
)
|
||||
InventoryItem.objects.bulk_create(inventory_items)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
@@ -1392,10 +1375,6 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cable
|
||||
brief_fields = ['id', 'label', 'url']
|
||||
bulk_update_data = {
|
||||
'length': 100,
|
||||
'length_unit': 'm',
|
||||
}
|
||||
|
||||
# TODO: Allow updating cable terminations
|
||||
test_update_object = None
|
||||
@@ -1452,6 +1431,377 @@ class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site = Site.objects.create(
|
||||
name='Test Site 1', slug='test-site-1'
|
||||
)
|
||||
manufacturer = Manufacturer.objects.create(
|
||||
name='Test Manufacturer 1', slug='test-manufacturer-1'
|
||||
)
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site
|
||||
)
|
||||
self.panel1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=self.site
|
||||
)
|
||||
self.panel2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=self.site
|
||||
)
|
||||
|
||||
def test_create_direct_console_connection(self):
|
||||
|
||||
consoleport1 = ConsolePort.objects.create(
|
||||
device=self.device1, name='Test Console Port 1'
|
||||
)
|
||||
consoleserverport1 = ConsoleServerPort.objects.create(
|
||||
device=self.device2, name='Test Console Server Port 1'
|
||||
)
|
||||
|
||||
data = {
|
||||
'termination_a_type': 'dcim.consoleport',
|
||||
'termination_a_id': consoleport1.pk,
|
||||
'termination_b_type': 'dcim.consoleserverport',
|
||||
'termination_b_id': consoleserverport1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cable.objects.count(), 1)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
|
||||
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
|
||||
|
||||
self.assertEqual(cable.termination_a, consoleport1)
|
||||
self.assertEqual(cable.termination_b, consoleserverport1)
|
||||
self.assertEqual(consoleport1.cable, cable)
|
||||
self.assertEqual(consoleserverport1.cable, cable)
|
||||
self.assertEqual(consoleport1.connected_endpoint, consoleserverport1)
|
||||
self.assertEqual(consoleserverport1.connected_endpoint, consoleport1)
|
||||
|
||||
def test_create_patched_console_connection(self):
|
||||
|
||||
consoleport1 = ConsolePort.objects.create(
|
||||
device=self.device1, name='Test Console Port 1'
|
||||
)
|
||||
consoleserverport1 = ConsoleServerPort.objects.create(
|
||||
device=self.device2, name='Test Console Server Port 1'
|
||||
)
|
||||
rearport1 = RearPort.objects.create(
|
||||
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
|
||||
)
|
||||
rearport2 = RearPort.objects.create(
|
||||
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Console port to panel1 front
|
||||
{
|
||||
'termination_a_type': 'dcim.consoleport',
|
||||
'termination_a_id': consoleport1.pk,
|
||||
'termination_b_type': 'dcim.frontport',
|
||||
'termination_b_id': frontport1.pk,
|
||||
},
|
||||
# Panel1 rear to panel2 rear
|
||||
{
|
||||
'termination_a_type': 'dcim.rearport',
|
||||
'termination_a_id': rearport1.pk,
|
||||
'termination_b_type': 'dcim.rearport',
|
||||
'termination_b_id': rearport2.pk,
|
||||
},
|
||||
# Panel2 front to console server port
|
||||
{
|
||||
'termination_a_type': 'dcim.frontport',
|
||||
'termination_a_id': frontport2.pk,
|
||||
'termination_b_type': 'dcim.consoleserverport',
|
||||
'termination_b_id': consoleserverport1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
for data in cables:
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cable.termination_a.cable, cable)
|
||||
self.assertEqual(cable.termination_b.cable, cable)
|
||||
|
||||
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
|
||||
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
|
||||
self.assertEqual(consoleport1.connected_endpoint, consoleserverport1)
|
||||
self.assertEqual(consoleserverport1.connected_endpoint, consoleport1)
|
||||
|
||||
def test_create_direct_power_connection(self):
|
||||
|
||||
powerport1 = PowerPort.objects.create(
|
||||
device=self.device1, name='Test Power Port 1'
|
||||
)
|
||||
poweroutlet1 = PowerOutlet.objects.create(
|
||||
device=self.device2, name='Test Power Outlet 1'
|
||||
)
|
||||
|
||||
data = {
|
||||
'termination_a_type': 'dcim.powerport',
|
||||
'termination_a_id': powerport1.pk,
|
||||
'termination_b_type': 'dcim.poweroutlet',
|
||||
'termination_b_id': poweroutlet1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cable.objects.count(), 1)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
powerport1 = PowerPort.objects.get(pk=powerport1.pk)
|
||||
poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk)
|
||||
|
||||
self.assertEqual(cable.termination_a, powerport1)
|
||||
self.assertEqual(cable.termination_b, poweroutlet1)
|
||||
self.assertEqual(powerport1.cable, cable)
|
||||
self.assertEqual(poweroutlet1.cable, cable)
|
||||
self.assertEqual(powerport1.connected_endpoint, poweroutlet1)
|
||||
self.assertEqual(poweroutlet1.connected_endpoint, powerport1)
|
||||
|
||||
# Note: Power connections via patch ports are not supported.
|
||||
|
||||
def test_create_direct_interface_connection(self):
|
||||
|
||||
interface1 = Interface.objects.create(
|
||||
device=self.device1, name='Test Interface 1'
|
||||
)
|
||||
interface2 = Interface.objects.create(
|
||||
device=self.device2, name='Test Interface 2'
|
||||
)
|
||||
|
||||
data = {
|
||||
'termination_a_type': 'dcim.interface',
|
||||
'termination_a_id': interface1.pk,
|
||||
'termination_b_type': 'dcim.interface',
|
||||
'termination_b_id': interface2.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cable.objects.count(), 1)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
interface2 = Interface.objects.get(pk=interface2.pk)
|
||||
|
||||
self.assertEqual(cable.termination_a, interface1)
|
||||
self.assertEqual(cable.termination_b, interface2)
|
||||
self.assertEqual(interface1.cable, cable)
|
||||
self.assertEqual(interface2.cable, cable)
|
||||
self.assertEqual(interface1.connected_endpoint, interface2)
|
||||
self.assertEqual(interface2.connected_endpoint, interface1)
|
||||
|
||||
def test_create_patched_interface_connection(self):
|
||||
|
||||
interface1 = Interface.objects.create(
|
||||
device=self.device1, name='Test Interface 1'
|
||||
)
|
||||
interface2 = Interface.objects.create(
|
||||
device=self.device2, name='Test Interface 2'
|
||||
)
|
||||
rearport1 = RearPort.objects.create(
|
||||
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
|
||||
)
|
||||
rearport2 = RearPort.objects.create(
|
||||
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Interface1 to panel1 front
|
||||
{
|
||||
'termination_a_type': 'dcim.interface',
|
||||
'termination_a_id': interface1.pk,
|
||||
'termination_b_type': 'dcim.frontport',
|
||||
'termination_b_id': frontport1.pk,
|
||||
},
|
||||
# Panel1 rear to panel2 rear
|
||||
{
|
||||
'termination_a_type': 'dcim.rearport',
|
||||
'termination_a_id': rearport1.pk,
|
||||
'termination_b_type': 'dcim.rearport',
|
||||
'termination_b_id': rearport2.pk,
|
||||
},
|
||||
# Panel2 front to interface2
|
||||
{
|
||||
'termination_a_type': 'dcim.frontport',
|
||||
'termination_a_id': frontport2.pk,
|
||||
'termination_b_type': 'dcim.interface',
|
||||
'termination_b_id': interface2.pk,
|
||||
},
|
||||
]
|
||||
|
||||
for data in cables:
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cable.termination_a.cable, cable)
|
||||
self.assertEqual(cable.termination_b.cable, cable)
|
||||
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
interface2 = Interface.objects.get(pk=interface2.pk)
|
||||
self.assertEqual(interface1.connected_endpoint, interface2)
|
||||
self.assertEqual(interface2.connected_endpoint, interface1)
|
||||
|
||||
def test_create_direct_circuittermination_connection(self):
|
||||
|
||||
provider = Provider.objects.create(
|
||||
name='Test Provider 1', slug='test-provider-1'
|
||||
)
|
||||
circuittype = CircuitType.objects.create(
|
||||
name='Test Circuit Type 1', slug='test-circuit-type-1'
|
||||
)
|
||||
circuit = Circuit.objects.create(
|
||||
provider=provider, type=circuittype, cid='Test Circuit 1'
|
||||
)
|
||||
interface1 = Interface.objects.create(
|
||||
device=self.device1, name='Test Interface 1'
|
||||
)
|
||||
circuittermination1 = CircuitTermination.objects.create(
|
||||
circuit=circuit, term_side='A', site=self.site, port_speed=10000
|
||||
)
|
||||
|
||||
data = {
|
||||
'termination_a_type': 'dcim.interface',
|
||||
'termination_a_id': interface1.pk,
|
||||
'termination_b_type': 'circuits.circuittermination',
|
||||
'termination_b_id': circuittermination1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cable.objects.count(), 1)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
|
||||
|
||||
self.assertEqual(cable.termination_a, interface1)
|
||||
self.assertEqual(cable.termination_b, circuittermination1)
|
||||
self.assertEqual(interface1.cable, cable)
|
||||
self.assertEqual(circuittermination1.cable, cable)
|
||||
self.assertEqual(interface1.connected_endpoint, circuittermination1)
|
||||
self.assertEqual(circuittermination1.connected_endpoint, interface1)
|
||||
|
||||
def test_create_patched_circuittermination_connection(self):
|
||||
|
||||
provider = Provider.objects.create(
|
||||
name='Test Provider 1', slug='test-provider-1'
|
||||
)
|
||||
circuittype = CircuitType.objects.create(
|
||||
name='Test Circuit Type 1', slug='test-circuit-type-1'
|
||||
)
|
||||
circuit = Circuit.objects.create(
|
||||
provider=provider, type=circuittype, cid='Test Circuit 1'
|
||||
)
|
||||
interface1 = Interface.objects.create(
|
||||
device=self.device1, name='Test Interface 1'
|
||||
)
|
||||
circuittermination1 = CircuitTermination.objects.create(
|
||||
circuit=circuit, term_side='A', site=self.site, port_speed=10000
|
||||
)
|
||||
rearport1 = RearPort.objects.create(
|
||||
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
|
||||
)
|
||||
rearport2 = RearPort.objects.create(
|
||||
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Interface to panel1 front
|
||||
{
|
||||
'termination_a_type': 'dcim.interface',
|
||||
'termination_a_id': interface1.pk,
|
||||
'termination_b_type': 'dcim.frontport',
|
||||
'termination_b_id': frontport1.pk,
|
||||
},
|
||||
# Panel1 rear to panel2 rear
|
||||
{
|
||||
'termination_a_type': 'dcim.rearport',
|
||||
'termination_a_id': rearport1.pk,
|
||||
'termination_b_type': 'dcim.rearport',
|
||||
'termination_b_id': rearport2.pk,
|
||||
},
|
||||
# Panel2 front to circuit termination
|
||||
{
|
||||
'termination_a_type': 'dcim.frontport',
|
||||
'termination_a_id': frontport2.pk,
|
||||
'termination_b_type': 'circuits.circuittermination',
|
||||
'termination_b_id': circuittermination1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
for data in cables:
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
cable = Cable.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cable.termination_a.cable, cable)
|
||||
self.assertEqual(cable.termination_b.cable, cable)
|
||||
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
|
||||
self.assertEqual(interface1.connected_endpoint, circuittermination1)
|
||||
self.assertEqual(circuittermination1.connected_endpoint, interface1)
|
||||
|
||||
|
||||
class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
@@ -1538,13 +1888,10 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
|
||||
)
|
||||
VirtualChassis.objects.bulk_create(virtual_chassis)
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis[0], vc_position=1)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
|
||||
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
|
||||
Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=virtual_chassis[1], vc_position=1)
|
||||
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
|
||||
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
|
||||
Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=virtual_chassis[2], vc_position=1)
|
||||
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
|
||||
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
|
||||
|
||||
@@ -1569,10 +1916,6 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'domain': 'newdomain',
|
||||
}
|
||||
|
||||
|
||||
class PowerPanelTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPanel
|
||||
@@ -1580,55 +1923,43 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
sites = (
|
||||
Site.objects.create(name='Site 1', slug='site-1'),
|
||||
Site.objects.create(name='Site 2', slug='site-2'),
|
||||
)
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
rack_groups = (
|
||||
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
|
||||
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]),
|
||||
RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]),
|
||||
RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]),
|
||||
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site),
|
||||
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=site),
|
||||
RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=site),
|
||||
)
|
||||
|
||||
power_panels = (
|
||||
PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'),
|
||||
PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'),
|
||||
PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'),
|
||||
PowerPanel(site=site, rack_group=rack_groups[0], name='Power Panel 1'),
|
||||
PowerPanel(site=site, rack_group=rack_groups[1], name='Power Panel 2'),
|
||||
PowerPanel(site=site, rack_group=rack_groups[2], name='Power Panel 3'),
|
||||
)
|
||||
PowerPanel.objects.bulk_create(power_panels)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Power Panel 4',
|
||||
'site': sites[0].pk,
|
||||
'site': site.pk,
|
||||
'rack_group': rack_groups[0].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Power Panel 5',
|
||||
'site': sites[0].pk,
|
||||
'site': site.pk,
|
||||
'rack_group': rack_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Power Panel 6',
|
||||
'site': sites[0].pk,
|
||||
'site': site.pk,
|
||||
'rack_group': rack_groups[2].pk,
|
||||
},
|
||||
]
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'site': sites[1].pk,
|
||||
'rack_group': rack_groups[3].pk
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerFeed
|
||||
brief_fields = ['cable', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user