Merge branch 'develop' into add_util_tooltip

This commit is contained in:
Josh VanDeraa 2020-12-27 10:23:27 -06:00 committed by GitHub
commit a07b74a6b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
490 changed files with 46994 additions and 22039 deletions

View File

@ -7,11 +7,9 @@ about: Report a reproducible bug in the current release of NetBox
<!--
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for reproducible bugs. If you need assistance with
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
This form is only for reporting reproducible bugs. If you need assistance
with NetBox installation, or if you have a general question, please start a
discussion instead: https://github.com/netbox-community/netbox/discussions
Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release

View File

@ -8,10 +8,8 @@ about: Propose a new NetBox feature or enhancement
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for proposing specific new features or enhancements.
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
If you have a general idea or question, please start a discussion instead:
https://github.com/netbox-community/netbox/discussions
NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope.

50
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
services:
redis:
image: redis
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies & set up configuration
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 netbox/
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*

View File

@ -1,19 +0,0 @@
sudo: required
services:
- postgresql
- redis-server
addons:
postgresql: "9.6"
language: python
python:
- "3.6"
- "3.7"
install:
- pip install -r requirements.txt
- pip install pycodestyle
- pip install coverage
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'
script:
- ./scripts/cibuild.sh

View File

@ -4,15 +4,31 @@ If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub issue
except to report bugs or request features.
### GitHub Discussions
GitHub's discussions are the best place to get help or propose rough ideas for
new functionality. Their integration with GitHub allows for easily cross-
referencing and converting posts to issues as needed. There are several
categories for discussions:
* **General** - General community discussion
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request
* **Q&A** - Request help with installing or using NetBox
### Mailing List
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).
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
for general discussion, however we're encouraging people to use GitHub
discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
## Reporting Bugs
@ -93,6 +109,10 @@ appropriate labels will be applied for categorization.
## Submitting Pull Requests
* If you're interested in contributing to NetBox, be sure to check out our
[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/)
documentation for tips on setting up your development environment.
* Be sure to open an issue **before** starting work on a pull request, and
discuss your idea with the NetBox maintainers before beginning work. This will
help prevent wasting time on something that might we might not be able to
@ -103,14 +123,18 @@ 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 major new functionality must include relevant tests where applicable.
* All 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):
@ -163,7 +187,7 @@ overlooked.
* Official channels for communication include:
* GitHub issues/pull requests
* GitHub issues, pull requests, and discussions
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)

View File

@ -7,20 +7,20 @@ to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
| | status |
|-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=master)](https://travis-ci.com/netbox-community/netbox/) |
| **develop** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=develop)](https://travis-ci.com/netbox-community/netbox/) |
| **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
| **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
### Screenshots
@ -36,19 +36,22 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
## Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
and run `upgrade.sh`.
Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`.
## Providing Feedback
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).
The best platform for general feedback, assistance, and other discussion is our
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
## Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

@ -1,26 +1,69 @@
# Custom Fields
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.
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.
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.
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.
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:
Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects.
* Free-form text (up to 255 characters)
* Integer
* Boolean (true/false)
* Date
* URL
* Selection
## Creating Custom Fields
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.
Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field:
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.)
* 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
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.
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.
## Using Custom Fields
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.
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.
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 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.
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"
}
}
```

View File

@ -4,10 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
!!! warning
Support for Django's native templating logic will be removed in NetBox v2.10.
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:

View File

@ -1,30 +0,0 @@
# 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
```

View File

@ -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, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1})
(1, {'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, {'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})
(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})
```
!!! warning

View File

@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
```python
BASE_PATH = 'netbox/'
@ -277,7 +277,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
Default: False
Setting this to True will display a "maintenance mode" banner at the top of every page.
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
---
@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python
NAPALM_ARGS = {
@ -460,7 +460,7 @@ NetBox can be configured to support remote user authentication by inferring user
Default: `'HTTP_REMOTE_USER'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
---

View File

@ -15,3 +15,4 @@
---
{!docs/models/ipam/vrf.md!}
{!docs/models/ipam/routetarget.md!}

View File

@ -1,8 +1,10 @@
# Application Registry
The registry is an in-memory data structure which houses various miscellaneous application-wide parameters, such as installed plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
The registry is an in-memory data structure which houses various application-wide parameters, such as the list of enabled plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, me modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, be modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
The registry can be inspected by importing `registry` from `extras.registry`.
## Stores

View File

@ -1,8 +1,8 @@
# Extending Models
Below is a list of items to consider when adding a new field to a model:
Below is a list of tasks to consider when adding a new field to a core model.
## 1. Generate and run database migration
## 1. Generate and run database migrations
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
@ -14,18 +14,18 @@ Django migrations are used to express changes to the database schema. In most ca
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
!!! note
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
## 2. Add validation logic to `clean()`
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or after your custom validation as appropriate:
```
class Foo(models.Model):
def clean(self):
super(DeviceCSVForm, self).clean()
super().clean()
# Custom validation goes here
if self.bar is None:
@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
## 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 5. Update API serializer
@ -49,7 +49,7 @@ Extend the model's API serializer in `<app>.api.serializers` to include the new
Extend any forms to include the new field as appropriate. Common forms include:
* **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on mnay objects at once
* **Bulk edit** - Performing a change on many objects at once
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
@ -59,7 +59,7 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
## 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
## 9. Update the UI templates
@ -76,3 +76,7 @@ Create or extend the relevant test cases to verify that the new field and any ac
* View tests
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
## 11. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@ -0,0 +1,140 @@
# Getting Started
## Setting up a Development Environment
Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need:
* A Linux system or environment
* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/)
* A Redis server, which can also be [installed locally](/installation/2-redis/)
* A supported version of Python
### Fork the Repo
Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development:
```no-highlight
$ git clone https://github.com/youruseraccount/netbox.git
Cloning into 'netbox'...
remote: Enumerating objects: 231, done.
remote: Counting objects: 100% (231/231), done.
remote: Compressing objects: 100% (147/147), done.
remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474
Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done.
Resolving deltas: 100% (44177/44177), done.
$ ls netbox/
base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
```
The NetBox project utilizes three persistent git branches to track work:
* `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable release occurs here
* `feature` - Tracks work on an upcoming major release
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
### Enable Pre-Commit Hooks
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
```no-highlight
$ cd .git/hooks/
$ ln -s ../../scripts/git-hooks/pre-commit
```
### Create a Python Virtual Environment
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
Create a virtual environment using the `venv` Python module:
```no-highlight
$ mkdir ~/.venv
$ python3 -m venv ~/.venv/netbox
```
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
!!! info
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please.
Once created, activate the virtual environment:
```no-highlight
$ source ~/.venv/netbox/bin/activate
(netbox) $
```
Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment.
### Install Dependencies
With the virtual environment activated, install the project's required Python packages using the `pip` module:
```no-highlight
(netbox) $ python -m pip install -r requirements.txt
Collecting Django==3.1 (from -r requirements.txt (line 1))
Cache entry deserialization failed, entry ignored
Using cached https://files.pythonhosted.org/packages/2b/5a/4bd5624546912082a1bd2709d0edc0685f5c7827a278d806a20cf6adea28/Django-3.1-py3-none-any.whl
...
```
### Configure NetBox
Within the `netbox/netbox/` directory, copy `configuration.example.py` to `configuration.py` and update the following parameters:
* `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes
* `DATABASE`: PostgreSQL database connection parameters
* `REDIS`: Redis configuration, if different from the defaults
* `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key)
* `DEBUG`: Set to `True`
* `DEVELOPER`: Set to `True` (this enables the creation of new database migrations)
### Start the Development Server
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command:
```no-highlight
$ python netbox/manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
November 18, 2020 - 15:52:31
Django version 3.1, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```
This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
## Running Tests
Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command:
```no-highlight
$ python netbox/manage.py test
```
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
```no-highlight
$ python netbox/manage.py test --keepdb
```
## Submitting Pull Requests
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it.
```no-highlight
$ git commit -m "Closes #1234: Add IPv5 support"
$ git push origin
```
Once your fork has the new commit, submit a [pull request](https://github.com/netbox-community/netbox/compare) to the NetBox repo to propose the changes. Be sure to provide a detailed accounting of the changes being made and the reasons for doing so.
Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
!!! note
Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted.

View File

@ -7,7 +7,8 @@ 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.
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - 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/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
* [#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
@ -18,7 +19,7 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base.
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function:
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function:
* `circuits`: Communications circuits and providers (not to be confused with power circuits)
* `dcim`: Datacenter infrastructure management (sites, racks, and devices)
@ -26,5 +27,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs)
* `secrets`: Encrypted storage of sensitive data (e.g. login credentials)
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
* `users`: Authentication and user preferences
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters

View File

@ -16,28 +16,24 @@ The other file is `requirements.txt`, which lists each of the required packages
Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
1. Create a new virtual environment.
2. Install the latest version of all required packages via pip:
```
pip install -U -r base_requirements.txt
```
2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
3. Run all tests and check that the UI and API function as expected.
4. Update the package versions in `requirements.txt` as appropriate.
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
5. Update the package versions in `requirements.txt` as appropriate.
### Update Static Libraries
Update the following static libraries to their most recent stable release:
* Bootstrap 3
* Font Awesome 4
* Material Design Icons
* Select2
* jQuery
* jQuery UI
### Create a new Release Notes Page
### Link to the Release Notes Page
Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for the new release. Add the file to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
### Manually Perform a New Install
@ -52,7 +48,11 @@ Follow these instructions to perform a new installation of NetBox. This process
### Close the Release Milestone
Close the release milestone on GitHub. Ensure that there are no remaining open issues associated with it.
Close the release milestone on GitHub after ensuring there are no remaining open issues associated with it.
### Merge the Release Branch
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
---
@ -64,11 +64,11 @@ Ensure that continuous integration testing on the `develop` branch is completing
### Update Version and Changelog
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version.
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version. Commit these changes to the `develop` branch.
### Submit a Pull Request
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Include a brief change log listing the features, improvements, and/or bugs addressed in the release.
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body.
Once CI has completed on the PR, merge it.
@ -76,16 +76,16 @@ Once CI has completed on the PR, merge it.
Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters.
* **Tag:** Current version (e.g. `v2.3.4`)
* **Tag:** Current version (e.g. `v2.9.9`)
* **Target:** `master`
* **Title:** Version and date (e.g. `v2.3.4 - 2018-08-02`)
* **Title:** Version and date (e.g. `v2.9.9 - 2020-11-09`)
Copy the description from the pull request into the release notes.
Copy the description from the pull request to the release.
### Update the Development Version
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.3.4, set:
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.9.9, set:
```
VERSION = 'v2.3.5-dev'
VERSION = 'v2.9.10-dev'
```

View File

@ -5,8 +5,8 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
## PEP 8 Exceptions
* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (`constants.py`)
* The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
* The library being import contains only constant declarations (e.g. `constants.py`)
* The library being imported explicitly defines `__all__`
* Maximum line length is 120 characters (E501)
* This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
@ -45,7 +45,7 @@ When adding a new dependency, a short description of the package and the URL of
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.

View File

@ -8,4 +8,4 @@ The `users.UserConfig` model holds individual preferences for each user in the f
| ---- | ----------- |
| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| tables.${table_name}.columns | The ordered list of columns to display when viewing the table |
| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table |

View File

@ -1,57 +0,0 @@
# Utility Views
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
## Individual Views
### ObjectView
Retrieve and display a single object.
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
### ObjectEditView
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
### ObjectDeleteView
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
## Bulk Views
### BulkCreateView
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
### BulkImportView
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
### BulkEditView
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
### BulkDeleteView
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
## Component Views
### ComponentCreateView
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
### ComponentEditView
A subclass of `ObjectEditView`: Updates an individual component object.
### ComponentDeleteView
A subclass of `ObjectDeleteView`: Deletes an individual component object.
### BulkComponentCreateView
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.

View File

@ -55,7 +55,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
## Supported Python Versions
NetBox supports Python 3.6 and 3.7 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.)
NetBox supports Python 3.6, 3.7, and 3.8 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.)
## Getting Started

View File

@ -5,30 +5,27 @@ 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
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).
Install the PostgreSQL server and client development libraries using `apt`.
```no-highlight
# apt-get update
# apt-get install -y postgresql libpq-dev
sudo apt update
sudo apt install -y postgresql libpq-dev
```
#### CentOS
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.
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/).
```no-highlight
# 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
sudo yum install -y postgresql-server libpq-devel
sudo postgresql-setup --initdb
```
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:
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:
```no-highlight
host all all 127.0.0.1/32 md5
@ -38,8 +35,8 @@ host all all ::1/128 md5
Then, start the service and enable it to run at boot:
```no-highlight
# systemctl start postgresql-9.6
# systemctl enable postgresql-9.6
sudo systemctl start postgresql
sudo systemctl enable postgresql
```
## Database Creation
@ -50,8 +47,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 (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
$ sudo -u postgres psql
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
Type "help" for help.
postgres=# CREATE DATABASE netbox;
@ -68,13 +65,16 @@ 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 (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.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 `\q` to exit.
If successful, you will enter a `netbox` prompt. Type `\conninfo` to confirm your connection, or type `\q` to exit.

View File

@ -10,16 +10,15 @@
### Ubuntu
```no-highlight
# apt-get install -y redis-server
sudo apt install -y redis-server
```
### CentOS
```no-highlight
# yum install -y epel-release
# yum install -y redis
# systemctl start redis
# systemctl enable redis
sudo yum install -y redis
sudo systemctl start redis
sudo 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.

View File

@ -7,40 +7,39 @@ 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 or 3.7. This documentation assumes Python 3.6.
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
### Ubuntu
```no-highlight
# 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
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
```
### CentOS
```no-highlight
# 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
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
```
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight
# pip3 install --upgrade pip
sudo pip3 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 decompressing 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 extracting 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
# 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
$ 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
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
```
@ -53,7 +52,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
# mkdir -p /opt/netbox/ && cd /opt/netbox/
sudo mkdir -p /opt/netbox/ && cd /opt/netbox/
```
If `git` is not already installed, install it:
@ -61,19 +60,19 @@ If `git` is not already installed, install it:
#### Ubuntu
```no-highlight
# apt-get install -y git
sudo apt install -y git
```
#### CentOS
```no-highlight
# yum install -y git
sudo 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
# git clone -b master https://github.com/netbox-community/netbox.git .
$ sudo 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.
@ -84,7 +83,7 @@ Checking connectivity... done.
```
!!! note
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
## Create the NetBox System User
@ -93,16 +92,16 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
#### Ubuntu
```
# adduser --system --group netbox
# chown --recursive netbox /opt/netbox/netbox/media/
sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
```
#### CentOS
```
# groupadd --system netbox
# adduser --system -g netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/
sudo groupadd --system netbox
sudo adduser --system -g netbox netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
```
## Configuration
@ -110,8 +109,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
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/
# cp configuration.example.py configuration.py
cd /opt/netbox/netbox/netbox/
sudo 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:
@ -154,7 +153,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 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 numeric database ID.
```python
REDIS = {
@ -182,7 +181,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
@ -199,7 +198,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
```no-highlight
# echo napalm >> /opt/netbox/local_requirements.txt
sudo echo napalm >> /opt/netbox/local_requirements.txt
```
### Remote File Storage
@ -207,7 +206,7 @@ The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to
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
# echo django-storages >> /opt/netbox/local_requirements.txt
sudo echo django-storages >> /opt/netbox/local_requirements.txt
```
## Run the Upgrade Script
@ -220,7 +219,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Aggregate static resource files on disk
```no-highlight
# /opt/netbox/upgrade.sh
sudo /opt/netbox/upgrade.sh
```
!!! note
@ -231,7 +230,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
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.
@ -239,8 +238,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:
@ -250,28 +249,33 @@ Superuser created successfully.
## Test the Application
At this point, we should be able to run NetBox. We can check by starting a development instance:
At this point, we should be able to run NetBox's development server for testing. 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 28, 2018 - 09:33:45
Django version 2.0.9, using settings 'netbox.settings'
November 17, 2020 - 16:08:13
Django version 3.1.3, 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. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
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.**
!!! 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 UI will be locked down for non-authenticated users.
Note that the initial user interface will be locked down for non-authenticated users.
![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_ui_guest.png)
Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI:
Try logging in using the superuser account we just created. Once authenticated, you'll be able to access all areas of the UI:
![NetBox UI as seen by an administrator](../media/installation/netbox_ui_admin.png)
Type `Ctrl+c` to stop the development server.

View File

@ -1,32 +1,31 @@
# 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/) for this role, however other WSGIs are available and should work similarly well.
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.
## 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 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 local changes to it do not get overwritten by a future upgrade.)
```no-highlight
# cd /opt/netbox
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
sudo cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py
```
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.
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.
## 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
# cp contrib/*.service /etc/systemd/system/
# systemctl daemon-reload
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload
```
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq
sudo systemctl start netbox netbox-rq
sudo systemctl enable netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
@ -34,16 +33,20 @@ 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 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/...
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>
...
```
!!! 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.

View File

@ -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/2.4), 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](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info
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.
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal 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
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/netbox.key \
-out /etc/ssl/certs/netbox.crt
```
@ -26,27 +26,26 @@ The above command will prompt you for additional details of the certificate; all
Begin by installing nginx:
```no-highlight
# apt-get install -y nginx
sudo apt 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
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
sudo 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
# cd /etc/nginx/sites-enabled/
# rm default
# ln -s /etc/nginx/sites-available/netbox
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
```
Finally, restart the `nginx` service to use the new configuration.
```no-highlight
# service nginx restart
sudo systemctl restart nginx
```
### Option B: Apache
@ -54,26 +53,26 @@ Finally, restart the `nginx` service to use the new configuration.
Begin by installing Apache:
```no-highlight
# apt-get install -y apache2
sudo apt install -y apache2
```
Next, copy the default configuration file to `/etc/apache2/sites-available/`. Be sure to modify the `ServerName` parameter appropriately.
```no-highlight
# cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
sudo 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
# a2enmod ssl proxy proxy_http headers
# a2ensite netbox
# service apache2 restart
sudo a2enmod ssl proxy proxy_http headers
sudo a2ensite netbox
sudo systemctl restart apache2
```
## Confirm Connectivity
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
At this point, you should be able to connect to the HTTPS 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.
@ -91,5 +90,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`

View File

@ -9,13 +9,13 @@ This guide explains how to implement LDAP authentication using an external serve
On Ubuntu:
```no-highlight
# apt-get install -y libldap2-dev libsasl2-dev libssl-dev
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
```
On CentOS:
```no-highlight
# yum install -y openldap-devel
sudo yum install -y openldap-devel
```
### Install django-auth-ldap
@ -23,15 +23,14 @@ On CentOS:
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
```no-highlight
# cd /opt/netbox/
# source venv/bin/activate
(venv) # pip3 install django-auth-ldap
source /opt/netbox/venv/bin/activate
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
(venv) # echo django-auth-ldap >> local_requirements.txt
sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt
```
## Configuration
@ -42,7 +41,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 `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 `/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](https://django-auth-ldap.readthedocs.io/).
### General Server Configuration

View File

@ -1,5 +1,7 @@
# 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)
@ -9,6 +11,14 @@ 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:
![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)

View File

@ -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 version 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 release 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 Code
## Install the Latest Release
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,47 +25,36 @@ 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
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
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
```
Copy the 'configuration.py' you created when first installing to the new version:
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
```
Copy your local requirements file if used:
```no-highlight
# cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/local_requirements.txt
```
Also copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
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/
```
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
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
```
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.)
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.)
```no-highlight
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
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/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight
# cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/gunicorn.py
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
```
### Option B: Clone the Git Repository
@ -73,10 +62,9 @@ If you followed the original installation guide to set up gunicorn, be sure to c
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
# git checkout master
# git pull origin master
# git status
cd /opt/netbox
sudo git checkout master
sudo git pull origin master
```
## Run the Upgrade Script
@ -84,10 +72,10 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
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
# ./upgrade.sh
sudo ./upgrade.sh
```
This script:
This script performs the following actions:
* Destroys and rebuilds the Python virtual environment
* Installs all required Python packages (listed in `requirements.txt`)
@ -99,23 +87,20 @@ This script:
* Clears all cached data to prevent conflicts with the new release
!!! note
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.
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.
## Restart the NetBox Services
!!! warning
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.
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.
Finally, restart the gunicorn and RQ services:
```no-highlight
# sudo systemctl restart netbox netbox-rq
sudo systemctl restart netbox netbox-rq
```
!!! note
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).
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.

View File

@ -0,0 +1,5 @@
# 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.

View File

@ -10,3 +10,5 @@ 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.

View File

@ -63,11 +63,15 @@ setup(
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:

View File

@ -1 +1 @@
version-2.9.md
version-2.10.md

View File

@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
### Enhancements

View File

@ -0,0 +1,197 @@
# NetBox v2.10
## v2.10.3 (FUTURE)
### Bug Fixes
* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
---
## v2.10.2 (2020-12-21)
### Enhancements
* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list
* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user
### Bug Fixes
* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
---
## v2.10.1 (2020-12-15)
### Bug Fixes
* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
---
## v2.10.0 (2020-12-14)
**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/`)
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view
### Other Changes
* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy
* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons
* [#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
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
* [#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 `/extras/custom-fields/` endpoint for custom fields
* Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint)
* Added the `/status/` endpoint to convey NetBox's current status
* circuits.CircuitTermination:
* Added the `/trace/` endpoint
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* `port_speed` may now be null
* 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`.

View File

@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
### Enhancements

View File

@ -1,17 +1,61 @@
# NetBox v2.9
## v2.9.9 (FUTURE)
## v2.9.11 (2020-12-11)
### Enhancements
* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command`
* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types
### Bug Fixes
* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API
* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names
* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned
* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list
* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext
* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms
* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported)
* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list
* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings
---
## v2.9.10 (2020-11-24)
### Enhancements
* [#5319](https://github.com/netbox-community/netbox/issues/5319) - Add USB types for power ports and outlets
* [#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
* [#5309](https://github.com/netbox-community/netbox/issues/5309) - Avoid extraneous database queries when manipulating objects
* [#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
* [#5355](https://github.com/netbox-community/netbox/issues/5355) - Limit rack groups by selected site when editing a rack
* [#5356](https://github.com/netbox-community/netbox/issues/5356) - Populate manufacturer field when adding a device component template
* [#5360](https://github.com/netbox-community/netbox/issues/5360) - Clear VLAN assignments when setting interface mode to none
---
## 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 assgined to an object
* [#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
---

View File

@ -2,7 +2,7 @@
## What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
* `GET`: Retrieve an object or list of objects
* `POST`: Create an object
@ -468,16 +468,16 @@ http://netbox/api/dcim/sites/ \
]
```
### Modifying an Object
### Updating 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,6 +515,23 @@ curl -s -X PATCH \
!!! 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.
@ -529,3 +546,18 @@ 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.

View File

@ -49,7 +49,6 @@ 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'
@ -69,13 +68,14 @@ nav:
- Working with Secrets: 'rest-api/working-with-secrets.md'
- Development:
- Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md'
- Style Guide: 'development/style-guide.md'
- Utility Views: 'development/utility-views.md'
- Extending Models: 'development/extending-models.md'
- Application Registry: 'development/application-registry.md'
- 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'

View File

@ -1,7 +1,7 @@
from rest_framework import serializers
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from utilities.api import WritableNestedSerializer
from netbox.api import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',

View File

@ -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 ConnectedEndpointSerializer
from dcim.api.serializers import CableTerminationSerializer, 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(ConnectedEndpointSerializer):
class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
@ -77,5 +77,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
model = CircuitTermination
fields = [
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable'
]

View File

@ -1,4 +1,4 @@
from utilities.api import OrderedDefaultRouter
from netbox.api import OrderedDefaultRouter
from . import views

View File

@ -1,15 +1,12 @@
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 django.db.models import Prefetch
from rest_framework.routers import APIRootView
from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.api.serializers import RenderedGraphSerializer
from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from utilities.api import ModelViewSet
from netbox.api.views import ModelViewSet
from utilities.utils import count_related
from . import serializers
@ -27,21 +24,11 @@ class CircuitsRootView(APIRootView):
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Count('circuits')
).order_by(*Provider._meta.ordering)
circuit_count=count_related(Circuit, 'provider')
)
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
@ -49,8 +36,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate(
circuit_count=Count('circuits')
).order_by(*CircuitType._meta.ordering)
circuit_count=count_related(Circuit, 'type')
)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet
@ -61,9 +48,7 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related(
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
'site', 'connected_endpoint__device'
)),
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')),
'type', 'tenant', 'provider',
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
@ -74,9 +59,9 @@ class CircuitViewSet(CustomFieldModelViewSet):
# Circuit Terminations
#
class CircuitTerminationViewSet(ModelViewSet):
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'connected_endpoint__device', 'cable'
'circuit', 'site', '_path__destination', 'cable'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet

View File

@ -23,6 +23,15 @@ 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

View File

@ -1,8 +1,9 @@
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 extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
@ -18,7 +19,7 @@ __all__ = (
)
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -72,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -144,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
).distinct()
class CircuitTerminationFilterSet(BaseFilterSet):
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -0,0 +1,22 @@
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),
),
]

View File

@ -0,0 +1,49 @@
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
),
]

View File

@ -0,0 +1,26 @@
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',
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,38 @@
# 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),
),
]

View File

@ -1,11 +1,9 @@
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
from dcim.models import CableTermination, PathEndpoint
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet
@ -22,17 +20,18 @@ __all__ = (
)
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
@extras_features('custom_fields', 'custom_links', '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=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
asn = ASNField(
@ -61,11 +60,6 @@ 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()
@ -105,10 +99,11 @@ class CircuitType(ChangeLoggedModel):
"Long Haul," "Metro," or "Out-of-Band".
"""
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
@ -145,7 +140,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
in Kbps.
"""
cid = models.CharField(
max_length=50,
max_length=100,
verbose_name='Circuit ID'
)
provider = models.ForeignKey(
@ -186,11 +181,6 @@ 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)
@ -202,15 +192,6 @@ 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']
@ -235,7 +216,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
return CircuitStatusChoices.CSS_CLASSES.get(self.status)
def _get_termination(self, side):
for ct in self.terminations.all():
@ -252,7 +233,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
return self._get_termination('Z')
class CircuitTermination(CableTermination):
class CircuitTermination(PathEndpoint, CableTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
@ -268,20 +249,10 @@ class CircuitTermination(CableTermination):
on_delete=models.PROTECT,
related_name='circuit_terminations'
)
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)'
verbose_name='Port speed (Kbps)',
blank=True,
null=True
)
upstream_speed = models.PositiveIntegerField(
blank=True,

View File

@ -2,13 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
#
# Providers
@ -64,9 +60,7 @@ class CircuitTable(BaseTable):
viewname='circuits:provider',
args=[Accessor('provider__slug')]
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)

View File

@ -1,11 +1,8 @@
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
@ -35,6 +32,9 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
'slug': 'provider-6',
},
]
bulk_update_data = {
'asn': 1234,
}
@classmethod
def setUpTestData(cls):
@ -46,27 +46,6 @@ 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
@ -85,6 +64,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
'slug': 'circuit-type-6',
},
)
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -100,6 +82,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
brief_fields = ['cid', 'id', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod
def setUpTestData(cls):
@ -168,10 +153,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
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(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.objects.bulk_create(circuit_terminations)
@ -189,3 +174,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
'port_speed': 200000,
},
]
cls.bulk_update_data = {
'port_speed': 123456
}

View File

@ -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 Region, Site
from dcim.models import Cable, 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', port_speed=1000),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000),
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
))
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', 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(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.objects.bulk_create(circuit_terminations)
@ -286,6 +286,8 @@ 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)
@ -313,3 +315,13 @@ 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)

View File

@ -1,6 +1,6 @@
from django.urls import path
from dcim.views import CableCreateView, CableTraceView
from dcim.views import CableCreateView, PathTraceView
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/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@ -1,15 +1,12 @@
from django.contrib import messages
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
from extras.models import Graph
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from utilities.utils import count_related
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -19,25 +16,24 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable
class ProviderView(ObjectView):
class ProviderView(generic.ObjectView):
queryset = Provider.objects.all()
def get(self, request, slug):
provider = get_object_or_404(self.queryset, slug=slug)
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider
provider=instance
).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')
@ -48,38 +44,40 @@ class ProviderView(ObjectView):
}
RequestConfig(request, paginate).configure(circuits_table)
return render(request, 'circuits/provider.html', {
'provider': provider,
return {
'circuits_table': circuits_table,
'show_graphs': show_graphs,
})
}
class ProviderEditView(ObjectEditView):
class ProviderEditView(generic.ObjectEditView):
queryset = Provider.objects.all()
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
class ProviderDeleteView(ObjectDeleteView):
class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
class ProviderBulkImportView(BulkImportView):
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
class ProviderBulkEditView(BulkEditView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
class ProviderBulkDeleteView(BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@ -88,28 +86,32 @@ class ProviderBulkDeleteView(BulkDeleteView):
# Circuit Types
#
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable
class CircuitTypeEditView(ObjectEditView):
class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
class CircuitTypeDeleteView(ObjectDeleteView):
class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(BulkImportView):
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
class CircuitTypeBulkDeleteView(BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable
@ -117,7 +119,7 @@ class CircuitTypeBulkDeleteView(BulkDeleteView):
# Circuits
#
class CircuitListView(ObjectListView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
).annotate_sites()
@ -126,52 +128,52 @@ class CircuitListView(ObjectListView):
table = tables.CircuitTable
class CircuitView(ObjectView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all()
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
def get_extra_context(self, request, instance):
# A-side termination
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
'site__region'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
if termination_a and termination_a.connected_endpoint:
if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
'site__region'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if termination_z and termination_z.connected_endpoint:
if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
return {
'termination_a': termination_a,
'termination_z': termination_z,
})
}
class CircuitEditView(ObjectEditView):
class CircuitEditView(generic.ObjectEditView):
queryset = Circuit.objects.all()
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
class CircuitDeleteView(ObjectDeleteView):
class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
class CircuitBulkImportView(BulkImportView):
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
class CircuitBulkEditView(BulkEditView):
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
)
@ -180,7 +182,7 @@ class CircuitBulkEditView(BulkEditView):
form = forms.CircuitBulkEditForm
class CircuitBulkDeleteView(BulkDeleteView):
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
)
@ -188,7 +190,7 @@ class CircuitBulkDeleteView(BulkDeleteView):
table = tables.CircuitTable
class CircuitSwapTerminations(ObjectEditView):
class CircuitSwapTerminations(generic.ObjectEditView):
"""
Swap the A and Z terminations of a circuit.
"""
@ -261,7 +263,7 @@ class CircuitSwapTerminations(ObjectEditView):
# Circuit terminations
#
class CircuitTerminationEditView(ObjectEditView):
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
@ -275,5 +277,5 @@ class CircuitTerminationEditView(ObjectEditView):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(ObjectDeleteView):
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()

View File

@ -1,8 +1,7 @@
from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim import models
from utilities.api import ChoiceField, WritableNestedSerializer
from netbox.api import WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
@ -228,51 +227,46 @@ 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', 'connection_status']
fields = ['id', 'url', 'device', 'name', 'cable']
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', 'connection_status']
fields = ['id', 'url', 'device', 'name', 'cable']
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', 'connection_status']
fields = ['id', 'url', 'device', 'name', 'cable']
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', 'connection_status']
fields = ['id', 'url', 'device', 'name', 'cable']
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', 'connection_status']
fields = ['id', 'url', 'device', 'name', 'cable']
class NestedRearPortSerializer(WritableNestedSerializer):
@ -305,10 +299,11 @@ 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']
fields = ['id', 'url', 'device', 'name', '_depth']
#

View File

@ -7,37 +7,57 @@ from rest_framework.validators import UniqueTogetherValidator
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
Cable, CablePath, 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 (
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer, get_serializer_for_model,
)
from utilities.api import 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)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
def get_connected_endpoint_type(self, obj):
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
)
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}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@ -45,14 +65,17 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
"""
Return the appropriate serializer for the type of connected object.
"""
if getattr(obj, 'connected_endpoint', None) is None:
return None
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
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
@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
#
@ -168,7 +191,7 @@ class RackUnitSerializer(serializers.Serializer):
occupied = serializers.BooleanField(read_only=True)
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer()
user = NestedUserSerializer()
@ -176,7 +199,7 @@ class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer
class Meta:
model = RackReservation
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields']
class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -452,7 +475,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@ -465,12 +488,12 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSeria
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
]
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@ -483,12 +506,12 @@ class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
]
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@ -511,12 +534,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
'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',
]
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@ -529,12 +553,13 @@ class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
'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',
]
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
@ -554,32 +579,26 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
model = Interface
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()
def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site.
# Validate many-to-many VLAN assignments
device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
"be global.".format(vlan)
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
f"it must be global."
})
return super().validate(data)
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@ -587,7 +606,10 @@ class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta:
model = RearPort
fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags']
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer',
'cable_peer_type', 'tags',
]
class FrontPortRearPortSerializer(WritableNestedSerializer):
@ -601,7 +623,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name', 'label']
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@ -612,7 +634,7 @@ class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
model = FrontPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
'tags',
'cable_peer', 'cable_peer_type', 'tags',
]
@ -636,12 +658,13 @@ 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',
'discovered', 'description', 'tags', '_depth',
]
@ -649,7 +672,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
# Cables
#
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@ -667,6 +690,7 @@ class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
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):
@ -706,6 +730,49 @@ 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.get_path():
serializer = get_serializer_for_model(node, prefix='Nested')
context = {'request': self.context['request']}
ret.append(serializer(node, context=context).data)
return ret
#
# Interface connections
#
@ -713,37 +780,43 @@ class TracedCableSerializer(serializers.ModelSerializer):
class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField()
interface_b = NestedInterfaceSerializer(source='connected_endpoint')
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Interface
fields = ['interface_a', 'interface_b', 'connection_status']
fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable']
@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, ValidatedModelSerializer):
class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
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', 'member_count']
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
#
# Power panels
#
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
@ -755,10 +828,15 @@ class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count']
class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class PowerFeedSerializer(
TaggedObjectSerializer,
CableTerminationSerializer,
ConnectedEndpointSerializer,
CustomFieldModelSerializer
):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@ -788,5 +866,7 @@ class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
model = PowerFeed
fields = [
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'cable',
'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated',
]

View File

@ -1,4 +1,4 @@
from utilities.api import OrderedDefaultRouter
from netbox.api import OrderedDefaultRouter
from . import views

View File

@ -2,7 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.db.models import Count, F
from django.db.models import F
from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
@ -17,21 +17,20 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit
from dcim import filters
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
Cable, CablePath, 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.serializers import RenderedGraphSerializer
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from extras.models import Graph
from ipam.models import Prefix, VLAN
from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
)
from utilities.utils import get_subquery
from utilities.metadata import ContentTypeMetadata
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.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@ -47,7 +46,7 @@ class DCIMRootView(APIRootView):
# Mixins
class CableTraceMixin(object):
class PathEndpointMixin(object):
@action(detail=True, url_path='trace')
def trace(self, request, pk):
@ -59,7 +58,10 @@ class CableTraceMixin(object):
# Initialize the path array
path = []
for near_end, cable, far_end in obj.trace()[0]:
for near_end, cable, far_end in obj.trace():
if near_end is None:
# Split paths
break
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
@ -79,6 +81,20 @@ class CableTraceMixin(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
#
@ -103,26 +119,16 @@ class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'site'),
rack_count=get_subquery(Rack, 'site'),
prefix_count=get_subquery(Prefix, 'site'),
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
).order_by(*Site._meta.ordering)
device_count=count_related(Device, 'site'),
rack_count=count_related(Rack, 'site'),
prefix_count=count_related(Prefix, 'site'),
vlan_count=count_related(VLAN, 'site'),
circuit_count=count_related(Circuit, 'terminations__site'),
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
)
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
@ -146,8 +152,8 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate(
rack_count=Count('racks')
).order_by(*RackRole._meta.ordering)
rack_count=count_related(Rack, 'role')
)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet
@ -160,9 +166,9 @@ class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
).order_by(*Rack._meta.ordering)
device_count=count_related(Device, 'rack'),
powerfeed_count=count_related(PowerFeed, 'rack')
)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet
@ -234,10 +240,10 @@ class RackReservationViewSet(ModelViewSet):
class ManufacturerViewSet(ModelViewSet):
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
).order_by(*Manufacturer._meta.ordering)
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet
@ -248,8 +254,8 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Count('instances')
).order_by(*DeviceType._meta.ordering)
device_count=count_related(Device, 'device_type')
)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
@ -312,9 +318,9 @@ class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
).order_by(*DeviceRole._meta.ordering)
device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet
@ -325,9 +331,9 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
).order_by(*Platform._meta.ordering)
device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet
@ -336,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
# Devices
#
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
class DeviceViewSet(ConfigContextQuerySetMixin, 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',
@ -363,17 +369,6 @@ 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(
@ -396,9 +391,7 @@ 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("No NAPALM driver is configured for this device's platform {}.".format(
device.platform
))
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
# Check for primary IP address from NetBox object
if device.primary_ip:
@ -407,21 +400,25 @@ 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 setup name resolution.")
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
f"setup name resolution.")
# Check that NAPALM is installed
try:
import napalm
from napalm.base.exceptions import ModuleImportError
except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'napalm':
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
raise e
# Validate the configured driver
try:
@ -492,59 +489,47 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
# Device components
#
class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilterSet
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
'device', '_path__destination', 'cable', '_cable_peer', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilterSet
class PowerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
)
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilterSet
class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilterSet
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
).filter(
device__isnull=False
'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
)
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(CableTraceMixin, ModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet
class RearPortViewSet(CableTraceMixin, ModelViewSet):
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet
@ -567,32 +552,26 @@ class InventoryItemViewSet(ModelViewSet):
#
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
queryset = ConsolePort.objects.prefetch_related('device', '_path').filter(
_path__destination_id__isnull=False
)
serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsoleConnectionFilterSet
class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
_connected_poweroutlet__isnull=False
queryset = PowerPort.objects.prefetch_related('device', '_path').filter(
_path__destination_id__isnull=False
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerConnectionFilterSet
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related(
'device', '_connected_interface__device'
).filter(
queryset = Interface.objects.prefetch_related('device', '_path').filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
_path__destination_id__isnull=False,
pk__lt=F('_path__destination_id')
)
serializer_class = serializers.InterfaceConnectionSerializer
filterset_class = filters.InterfaceConnectionFilterSet
@ -617,8 +596,8 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=Count('members', distinct=True)
).order_by(*VirtualChassis._meta.ordering)
member_count=count_related(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
@ -631,8 +610,8 @@ class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
).order_by(*PowerPanel._meta.ordering)
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet
@ -641,8 +620,10 @@ class PowerPanelViewSet(ModelViewSet):
# Power feeds
#
class PowerFeedViewSet(CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filters.PowerFeedFilterSet
@ -697,7 +678,7 @@ class ConnectedDeviceViewSet(ViewSet):
device__name=peer_device_name,
name=peer_interface_name
)
local_interface = peer_interface._connected_interface
local_interface = peer_interface.connected_endpoint
if local_interface is None:
return Response()

View File

@ -21,6 +21,14 @@ class SiteStatusChoices(ChoiceSet):
(STATUS_RETIRED, 'Retired'),
)
CSS_CLASSES = {
STATUS_PLANNED: 'info',
STATUS_STAGING: 'primary',
STATUS_ACTIVE: 'success',
STATUS_DECOMMISSIONING: 'warning',
STATUS_RETIRED: 'danger',
}
#
# Racks
@ -74,6 +82,14 @@ 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):
@ -147,6 +163,16 @@ 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
@ -278,6 +304,16 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
TYPE_USB_MINI_A = 'usb-mini-a'
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
CHOICES = (
('IEC 60320', (
@ -367,6 +403,17 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
(TYPE_USB_B, 'USB Type B'),
(TYPE_USB_C, 'USB Type C'),
(TYPE_USB_MINI_A, 'USB Mini A'),
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
)
@ -456,6 +503,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
# Proprietary
TYPE_HDOT_CX = 'hdot-cx'
@ -546,6 +597,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
('Proprietary', (
(TYPE_HDOT_CX, 'HDOT Cx'),
)),
@ -830,6 +886,9 @@ class PortTypeChoices(ChoiceSet):
TYPE_MPO = 'mpo'
TYPE_LSH = 'lsh'
TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
CHOICES = (
(
@ -857,6 +916,9 @@ class PortTypeChoices(ChoiceSet):
(TYPE_SC, 'SC'),
(TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_SPLICE, 'Splice'),
)
)
)
@ -933,6 +995,12 @@ class CableStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
CSS_CLASSES = {
STATUS_CONNECTED: 'success',
STATUS_PLANNED: 'info',
STATUS_DECOMMISSIONING: 'warning',
}
class CableLengthUnitChoices(ChoiceSet):
@ -967,6 +1035,13 @@ class PowerFeedStatusChoices(ChoiceSet):
(STATUS_FAILED, 'Failed'),
)
CSS_CLASSES = {
STATUS_OFFLINE: 'warning',
STATUS_ACTIVE: 'success',
STATUS_PLANNED: 'info',
STATUS_FAILED: 'danger',
}
class PowerFeedTypeChoices(ChoiceSet):
@ -978,6 +1053,11 @@ class PowerFeedTypeChoices(ChoiceSet):
(TYPE_REDUNDANT, 'Redundant'),
)
CSS_CLASSES = {
TYPE_PRIMARY: 'success',
TYPE_REDUNDANT: 'info',
}
class PowerFeedSupplyChoices(ChoiceSet):

View File

@ -59,12 +59,6 @@ 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=(
@ -83,12 +77,13 @@ 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'],
}

View File

@ -1,14 +0,0 @@
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

View File

@ -1,9 +1,11 @@
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):
@ -50,3 +52,15 @@ 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)

View File

@ -1,7 +1,8 @@
import django_filters
from django.contrib.auth.models import User
from django.db.models import Count
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
@ -23,6 +24,7 @@ from .models import (
__all__ = (
'CableFilterSet',
'CableTerminationFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@ -40,6 +42,7 @@ __all__ = (
'InterfaceTemplateFilterSet',
'InventoryItemFilterSet',
'ManufacturerFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet',
'PowerConnectionFilterSet',
'PowerFeedFilterSet',
@ -77,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -176,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -221,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
choices=RackStatusChoices,
null_value=None
)
type = django_filters.MultipleChoiceFilter(
choices=RackTypeChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(),
label='Role (ID)',
@ -239,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit',
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit',
]
def search(self, queryset, name, value):
@ -293,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
label='User (ID)',
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user',
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label='User (name)',
@ -322,7 +331,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -501,7 +510,7 @@ class DeviceFilterSet(
BaseFilterSet,
TenancyFilterSet,
LocalConfigContextFilterSet,
CustomFieldFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(
@ -746,71 +755,76 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
)
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
)
class CableTerminationFilterSet(django_filters.FilterSet):
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', 'connection_status']
fields = ['id', 'name', 'description']
class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class ConsoleServerPortFilterSet(
BaseFilterSet,
DeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
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', 'connection_status']
fields = ['id', 'name', 'description']
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
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', 'connection_status']
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
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', 'connection_status']
fields = ['id', 'name', 'feed_leg', 'description']
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -827,11 +841,6 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
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',
@ -858,7 +867,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class Meta:
model = Interface
fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
def filter_device(self, queryset, name, value):
try:
@ -908,24 +917,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
}.get(value, queryset.none())
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class Meta:
model = FrontPort
fields = ['id', 'name', 'type', 'description']
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class Meta:
model = RearPort
@ -1133,7 +1132,20 @@ class CableFilterSet(BaseFilterSet):
return queryset
class ConsoleConnectionFilterSet(BaseFilterSet):
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):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@ -1148,23 +1160,10 @@ class ConsoleConnectionFilterSet(BaseFilterSet):
class Meta:
model = ConsolePort
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})
)
fields = ['name']
class PowerConnectionFilterSet(BaseFilterSet):
class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@ -1179,23 +1178,10 @@ class PowerConnectionFilterSet(BaseFilterSet):
class Meta:
model = PowerPort
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})
)
fields = ['name']
class InterfaceConnectionFilterSet(BaseFilterSet):
class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@ -1210,23 +1196,7 @@ class InterfaceConnectionFilterSet(BaseFilterSet):
class Meta:
model = Interface
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})
)
fields = []
class PowerPanelFilterSet(BaseFilterSet):
@ -1278,7 +1248,13 @@ class PowerPanelFilterSet(BaseFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class PowerFeedFilterSet(
BaseFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@ -21,10 +21,11 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK,
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
)
class InterfaceCommonForm:
class InterfaceCommonForm(forms.Form):
def clean(self):
super().clean()
# Validate VLAN assignments
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs
@ -109,13 +109,13 @@ class InterfaceCommonForm:
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
valid_sites = [None, self.cleaned_data['device'].site]
valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
if invalid_vlans:
raise forms.ValidationError({
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
"device/VM, or they must be global".format(', '.join(invalid_vlans))
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
f"the interface's parent device/VM, or they must be global"
})
@ -470,6 +470,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region'
}
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
query_params={
'site_id': '$site'
}
)
role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
required=False
@ -683,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
widget=StaticSelect2Multiple()
)
type = forms.MultipleChoiceField(
choices=RackTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False,
widget=StaticSelect2Multiple()
)
role = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(),
to_field_name='slug',
@ -714,7 +731,7 @@ class RackElevationFilterForm(RackFilterForm):
# Rack reservations
#
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@ -741,7 +758,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
display_field='display_name',
query_params={
'site_id': '$site',
'group_id': '$rack',
'group_id': '$rack_group',
}
)
units = NumericArrayField(
@ -843,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
@ -867,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
label='Rack group',
null_option='None'
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)
)
tag = TagFilterField(model)
@ -915,7 +941,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'subdevice_role': StaticSelect2()
'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
'front_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
}),
'rear_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
})
}
@ -1027,7 +1060,10 @@ class ComponentTemplateCreateForm(ComponentForm):
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
required=False,
initial_params={
'device_types': 'device_type'
}
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
@ -1745,9 +1781,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'group_id': '$rack_group',
}
)
position = forms.TypedChoiceField(
position = forms.IntegerField(
required=False,
empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
@ -1820,6 +1855,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
"config context",
}
widgets = {
'face': StaticSelect2(),
'status': StaticSelect2(),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@ -1866,6 +1902,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
)
# Disable rack assignment if this is a child device installed in a parent device
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
else:
# An object that doesn't exist yet can't have any IPs assigned to it
@ -1875,31 +1918,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Rack position
pk = self.instance.pk if self.instance.pk else None
try:
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack']) \
.get_rack_units(face=self.data.get('face'), exclude=pk)
elif self.initial.get('rack') and str(self.initial.get('face')):
position_choices = Rack.objects.get(pk=self.initial['rack']) \
.get_rack_units(face=self.initial.get('face'), exclude=pk)
else:
position_choices = []
except Rack.DoesNotExist:
position_choices = []
self.fields['position'].choices = [('', '---------')] + [
(p['id'], {
'label': p['name'],
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
}) for p in position_choices
]
# Disable rack assignment if this is a child device installed in a parent device
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
position = self.data.get('position') or self.initial.get('position')
if position:
self.fields['position'].widget.choices = [(position, f'U{position}')]
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
@ -2671,7 +2692,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
@ -2822,7 +2843,7 @@ class InterfaceBulkCreateForm(
class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
]),
BootstrapMixin,
AddRemoveTagsForm,
@ -2838,6 +2859,15 @@ class InterfaceBulkEditForm(
disabled=True,
widget=forms.HiddenInput()
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label='Management only'
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
@ -3344,6 +3374,13 @@ 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
@ -3356,7 +3393,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InventoryItem
fields = [
'name', 'label', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'tags',
]
@ -3365,6 +3403,13 @@ 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,
@ -3379,8 +3424,8 @@ class InventoryItemCreateForm(ComponentCreateForm):
required=False,
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'tags',
'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'tags',
)
@ -3455,7 +3500,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
# Cables
#
class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
"""
Base form for connecting a Cable to a Device component
"""
@ -3492,12 +3537,16 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
'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',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect2,
@ -3588,7 +3637,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
)
class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
@ -3625,12 +3674,16 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'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',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
@ -3638,7 +3691,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
@ -3678,12 +3731,16 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
'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',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
@ -3691,7 +3748,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class CableForm(BootstrapMixin, forms.ModelForm):
class CableForm(BootstrapMixin, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@ -3721,10 +3778,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name',
help_text='Side A device'
)
side_a_type = CSVModelChoiceField(
side_a_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side A type'
)
side_a_name = forms.CharField(
@ -3737,10 +3793,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name',
help_text='Side B device'
)
side_b_type = CSVModelChoiceField(
side_b_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side B type'
)
side_b_name = forms.CharField(
@ -3774,58 +3829,36 @@ class CableCSVForm(CSVModelForm):
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
# TODO: Merge the clean() methods for either end
def clean_side_a_name(self):
def _clean_side(self, side):
"""
Derive a Cable's A/B termination objects.
device = self.cleaned_data.get('side_a_device')
content_type = self.cleaned_data.get('side_a_type')
name = self.cleaned_data.get('side_a_name')
:param side: 'a' or 'b'
"""
assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name:
return None
model = content_type.model_class()
try:
termination_object = model.objects.get(
device=device,
name=name
)
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None:
raise forms.ValidationError(
"Side A: {} {} is already connected".format(device, termination_object)
)
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:
raise forms.ValidationError(
"A side termination not found: {} {}".format(device, name)
)
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
self.instance.termination_a = termination_object
setattr(self.instance, f'termination_{side}', termination_object)
return termination_object
def clean_side_a_name(self):
return self._clean_side('a')
def clean_side_b_name(self):
device = self.cleaned_data.get('side_b_device')
content_type = self.cleaned_data.get('side_b_type')
name = self.cleaned_data.get('side_b_name')
if not device or not content_type or not name:
return None
model = content_type.model_class()
try:
termination_object = model.objects.get(
device=device,
name=name
)
if termination_object.cable is not None:
raise forms.ValidationError(
"Side B: {} {} is already connected".format(device, termination_object)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
"B side termination not found: {} {}".format(device, name)
)
self.instance.termination_b = termination_object
return termination_object
return self._clean_side('b')
def clean_length_unit(self):
# Avoid trying to save as NULL
@ -4034,7 +4067,7 @@ class DeviceSelectionForm(forms.Form):
)
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@ -4097,7 +4130,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
return instance
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
master = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
@ -4287,7 +4320,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Power panels
#
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,

10
netbox/dcim/lookups.py Normal file
View File

@ -0,0 +1,10 @@
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()

View File

View File

@ -0,0 +1,88 @@
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 draw_progress_bar(self, percentage):
"""
Draw a simple progress bar 20 increments wide illustrating the specified percentage.
"""
bar_size = int(percentage / 5)
self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20-bar_size)}] {int(percentage)}%", ending='')
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:
self.stdout.write(f'Found no missing {model._meta.verbose_name} paths; skipping')
continue
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath(obj)
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)
self.stdout.write(self.style.SUCCESS(f'\n Retraced {i} {model._meta.verbose_name_plural}'))
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -0,0 +1,60 @@
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),
),
]

View File

@ -0,0 +1,44 @@
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'),
),
]

View File

@ -0,0 +1,26 @@
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
),
]

View File

@ -0,0 +1,141 @@
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
),
]

View File

@ -0,0 +1,108 @@
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',
),
]

View File

@ -0,0 +1,98 @@
# 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),
),
]

View File

@ -1,3 +1,4 @@
from .cables import *
from .device_component_templates import *
from .device_components import *
from .devices import *
@ -8,6 +9,7 @@ from .sites import *
__all__ = (
'BaseInterface',
'Cable',
'CablePath',
'CableTermination',
'ConsolePort',
'ConsolePortTemplate',

View File

@ -0,0 +1,496 @@
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):
pk = self.pk or self._pk
return self.label or f'#{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 (if any)
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
# Determine the peer FrontPort's position
if peer_termination.positions == 1:
position = 1
elif position_stack:
position = position_stack.pop()
else:
# No position indicated: path has split, so we stop at the RearPort
is_split = True
break
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
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)

View File

@ -1,20 +1,20 @@
import logging
from django.contrib.contenttypes.fields import GenericRelation
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 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,6 +30,7 @@ __all__ = (
'FrontPort',
'Interface',
'InventoryItem',
'PathEndpoint',
'PowerOutlet',
'PowerPort',
'RearPort',
@ -37,6 +38,9 @@ __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,
@ -91,6 +95,14 @@ 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,
@ -98,6 +110,21 @@ 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(
@ -114,138 +141,57 @@ class CableTermination(models.Model):
class Meta:
abstract = True
def trace(self):
"""
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)
]
"""
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
return self._cable_peer
def get_path_endpoints(self):
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):
"""
Return all endpoints of paths which traverse this object.
Caching accessor for the attached CablePath's destination (if any)
"""
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
if not hasattr(self, '_connected_endpoint'):
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
#
@ -253,7 +199,7 @@ class CableTermination(models.Model):
#
@extras_features('export_templates', 'webhooks')
class ConsolePort(CableTermination, ComponentModel):
class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@ -263,18 +209,6 @@ class ConsolePort(CableTermination, 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']
@ -301,7 +235,7 @@ class ConsolePort(CableTermination, ComponentModel):
#
@extras_features('webhooks')
class ConsoleServerPort(CableTermination, ComponentModel):
class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@ -311,11 +245,6 @@ class ConsoleServerPort(CableTermination, 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']
@ -342,7 +271,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
#
@extras_features('export_templates', 'webhooks')
class PowerPort(CableTermination, ComponentModel):
class PowerPort(CableTermination, PathEndpoint, ComponentModel):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@ -364,25 +293,6 @@ class PowerPort(CableTermination, 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']
@ -413,51 +323,18 @@ class PowerPort(CableTermination, ComponentModel):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
@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
@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):
"""
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
"""
# 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(_connected_poweroutlet_id__in=outlet_ids).aggregate(
utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
@ -469,10 +346,13 @@ class PowerPort(CableTermination, ComponentModel):
}
# Calculate per-leg aggregates for three-phase feeds
if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
if getattr(self._cable_peer, 'phase', None) == 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(_connected_poweroutlet_id__in=outlet_ids).aggregate(
utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
@ -499,7 +379,7 @@ class PowerPort(CableTermination, ComponentModel):
#
@extras_features('webhooks')
class PowerOutlet(CableTermination, ComponentModel):
class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@ -522,11 +402,6 @@ class PowerOutlet(CableTermination, 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']
@ -589,9 +464,21 @@ class BaseInterface(models.Model):
class Meta:
abstract = True
def save(self, *args, **kwargs):
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface):
# Remove untagged VLAN assignment for non-802.1Q interfaces
if not self.mode:
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear()
return super().save(*args, **kwargs)
@extras_features('export_templates', 'webhooks')
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@ -602,25 +489,6 @@ class Interface(CableTermination, 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,
@ -724,54 +592,6 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
"device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces
if self.mode is None:
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear()
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
@ -975,17 +795,18 @@ class DeviceBay(ComponentModel):
#
@extras_features('export_templates', 'webhooks')
class InventoryItem(ComponentModel):
class InventoryItem(MPTTModel, 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 = models.ForeignKey(
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='child_items',
blank=True,
null=True
null=True,
db_index=True
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
@ -1020,6 +841,8 @@ class InventoryItem(ComponentModel):
tags = TaggableManager(through=TaggedItem)
objects = TreeManager()
csv_headers = [
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
]

View File

@ -2,9 +2,8 @@ from collections import OrderedDict
import yaml
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
@ -20,12 +19,10 @@ 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',
@ -45,10 +42,11 @@ class Manufacturer(ChangeLoggedModel):
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
"""
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
@ -99,9 +97,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
related_name='device_types'
)
model = models.CharField(
max_length=50
max_length=100
)
slug = models.SlugField(
max_length=100
)
slug = models.SlugField()
part_number = models.CharField(
max_length=50,
blank=True,
@ -135,11 +135,6 @@ 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()
@ -259,6 +254,7 @@ 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
@ -349,10 +345,11 @@ class DeviceRole(ChangeLoggedModel):
virtual machines as well.
"""
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
color = ColorField(
@ -399,8 +396,8 @@ class Platform(ChangeLoggedModel):
unique=True
)
slug = models.SlugField(
unique=True,
max_length=100
max_length=100,
unique=True
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
@ -451,7 +448,7 @@ class Platform(ChangeLoggedModel):
)
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
@extras_features('custom_fields', 'custom_links', '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,
@ -585,14 +582,15 @@ 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()
@ -605,16 +603,6 @@ 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 = (
@ -647,13 +635,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
super().validate_unique(exclude)
def clean(self):
super().clean()
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
if self.rack is None:
@ -663,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
'position': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
@ -675,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Prevent 0U devices from being assigned to a specific position
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
})
if self.rack:
@ -701,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
'position': f"U{self.position} is already occupied or does not have sufficient space to "
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
})
except DeviceType.DoesNotExist:
@ -868,6 +855,7 @@ 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
@ -886,301 +874,15 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
return Device.objects.filter(parent_bay__device=self.pk)
def get_status_class(self):
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]
return DeviceStatusChoices.CSS_CLASSES.get(self.status)
#
# Virtual chassis
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class VirtualChassis(ChangeLoggedModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VirtualChassis(ChangeLoggedModel, CustomFieldModel):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
"""
@ -1215,6 +917,7 @@ class VirtualChassis(ChangeLoggedModel):
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.)

View File

@ -1,4 +1,3 @@
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
@ -11,7 +10,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
from .device_components import CableTermination, PathEndpoint
__all__ = (
'PowerFeed',
@ -23,8 +22,8 @@ __all__ = (
# Power
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class PowerPanel(ChangeLoggedModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerPanel(ChangeLoggedModel, CustomFieldModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
"""
@ -39,7 +38,7 @@ class PowerPanel(ChangeLoggedModel):
null=True
)
name = models.CharField(
max_length=50
max_length=100
)
tags = TaggableManager(through=TaggedItem)
@ -65,6 +64,7 @@ class PowerPanel(ChangeLoggedModel):
)
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):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel):
"""
An electrical circuit delivered from a PowerPanel.
"""
@ -89,20 +89,8 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
blank=True,
null=True
)
connected_endpoint = models.OneToOneField(
to='dcim.PowerPort',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
name = models.CharField(
max_length=50
max_length=100
)
status = models.CharField(
max_length=50,
@ -144,11 +132,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, 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()
@ -162,18 +145,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
'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']
@ -202,6 +173,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
)
def clean(self):
super().clean()
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
@ -231,7 +203,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
return self.power_panel
def get_type_class(self):
return self.TYPE_CLASS_MAP.get(self.type)
return PowerFeedTypeChoices.CSS_CLASSES.get(self.type)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
return PowerFeedStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -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,7 +22,8 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import serialize_object
from utilities.utils import array_to_string, serialize_object
from .device_components import PowerOutlet, PowerPort
from .devices import Device
from .power import PowerFeed
@ -46,9 +47,11 @@ 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=50
max_length=100
)
slug = models.SlugField(
max_length=100
)
slug = models.SlugField()
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
@ -117,10 +120,11 @@ class RackRole(ChangeLoggedModel):
Racks can be organized by functional role, similar to Devices.
"""
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
color = ColorField(
@ -160,7 +164,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
Each Rack is assigned to a Site and (optionally) a RackGroup.
"""
name = models.CharField(
max_length=50
max_length=100
)
_name = NaturalOrderingField(
target_field='name',
@ -261,11 +265,6 @@ 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'
)
@ -282,14 +281,6 @@ 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 = (
@ -305,6 +296,7 @@ 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:
@ -334,22 +326,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
'group': "Rack group must be from the same site, {}.".format(self.site)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super().save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
devices = Device.objects.filter(rack=self)
for device in devices:
device.site = self.site
device.save()
def to_csv(self):
return (
self.site.name,
@ -385,7 +361,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return self.name
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
return RackStatusChoices.CSS_CLASSES.get(self.status)
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
"""
@ -550,14 +526,20 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
"""
Determine the utilization rate of power in the rack and return it as a percentage.
"""
power_stats = PowerFeed.objects.filter(
rack=self
).annotate(
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
).values(
'allocated_draw_total',
'available_power'
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)
)
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
if power_stats:
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
@ -566,8 +548,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return (0, 0)
@extras_features('custom_links', 'export_templates', 'webhooks')
class RackReservation(ChangeLoggedModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class RackReservation(ChangeLoggedModel, CustomFieldModel):
"""
One or more reserved units within a Rack.
"""
@ -609,6 +591,7 @@ class RackReservation(ChangeLoggedModel):
return reverse('dcim:rackreservation', args=[self.pk])
def clean(self):
super().clean()
if hasattr(self, 'rack') and self.units:
@ -647,9 +630,4 @@ class RackReservation(ChangeLoggedModel):
@property
def unit_list(self):
"""
Express the assigned units as a string of summarized ranges. For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
return array_to_string(self.units)

View File

@ -39,10 +39,11 @@ class Region(MPTTModel, ChangeLoggedModel):
db_index=True
)
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
@ -91,14 +92,14 @@ class Region(MPTTModel, ChangeLoggedModel):
# Sites
#
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Site(ChangeLoggedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
"""
name = models.CharField(
max_length=50,
max_length=100,
unique=True
)
_name = NaturalOrderingField(
@ -107,6 +108,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
blank=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
status = models.CharField(
@ -183,11 +185,6 @@ 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'
)
@ -204,14 +201,6 @@ 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',)
@ -243,4 +232,4 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
return SiteStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -1,12 +1,78 @@
import logging
from django.db.models.signals import post_save, pre_delete
from cacheops import invalidate_obj
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.dispatch import receiver
from .choices import CableStatusChoices
from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, 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:
invalidate_obj(cp.origin)
cp.delete()
create_cablepath(cp.origin)
#
# Site/rack/device assignment
#
@receiver(post_save, sender=RackGroup)
def handle_rackgroup_site_change(instance, created, **kwargs):
"""
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
"""
if not created:
for rackgroup in instance.get_children():
rackgroup.site = instance.site
rackgroup.save()
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
rack.site = instance.site
rack.save()
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
powerpanel.site = instance.site
powerpanel.save()
@receiver(post_save, sender=Rack)
def handle_rack_site_change(instance, created, **kwargs):
"""
Update child Devices if Site assignment has changed.
"""
if not created:
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
device.site = instance.site
device.save()
#
# Virtual chassis
#
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
@ -31,72 +97,79 @@ def clear_virtualchassis_members(instance, **kwargs):
device.save()
#
# Cables
#
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, **kwargs):
def update_connected_endpoints(instance, created, raw=False, **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("Updating termination A for cable {}".format(instance))
logger.debug(f"Updating termination A for cable {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("Updating termination B for cable {}".format(instance))
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
instance.termination_b._cable_peer = instance.termination_a
instance.termination_b.save()
# 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()
# 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)
@receiver(pre_delete, sender=Cable)
@receiver(post_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("Nullifying termination A for cable {}".format(instance))
logger.debug(f"Nullifying termination A for cable {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("Nullifying termination B for cable {}".format(instance))
logger.debug(f"Nullifying termination B for cable {instance}")
instance.termination_b.cable = None
instance.termination_b._cable_peer = None
instance.termination_b.save()
# 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()
# 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()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
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')

View File

@ -0,0 +1,64 @@
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',
)

View File

@ -0,0 +1,643 @@
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 '',
'data-name': lambda record: record.name,
}
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')

View File

@ -0,0 +1,200 @@
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"

View File

@ -0,0 +1,77 @@
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',
)

170
netbox/dcim/tables/racks.py Normal file
View File

@ -0,0 +1,170 @@
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',
)

View File

@ -0,0 +1,62 @@
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')

View File

@ -0,0 +1,248 @@
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 %}
&mdash;
{% endif %}
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% 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 = """
{% if record.mode == 'tagged' %}
{% for vlan in record.tagged_vlans.all %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %}
{% elif record.mode == 'tagged-all' %}
All
{% else %}
&mdash;
{% endif %}
"""
MPTT_LINK = """
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.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=object.pk %}" class="btn btn-success btn-xs">
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
</a>
{% endif %}
{% endif %}
"""

View File

@ -1,10 +1,7 @@
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 (
@ -14,7 +11,6 @@ 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
@ -83,6 +79,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
'slug': 'region-6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -95,6 +94,9 @@ 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):
@ -132,30 +134,13 @@ 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):
@ -217,6 +202,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
'color': 'ffff00',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -232,6 +220,9 @@ 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):
@ -317,6 +308,9 @@ 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):
@ -379,6 +373,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
'slug': 'manufacturer-6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -394,6 +391,9 @@ 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,6 +433,9 @@ 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):
@ -467,6 +470,9 @@ 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):
@ -501,6 +507,9 @@ 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):
@ -535,6 +544,9 @@ 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):
@ -569,6 +581,9 @@ 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):
@ -606,6 +621,9 @@ 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):
@ -652,21 +670,21 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Front Port Template 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[3].pk,
'position': 1,
'rear_port_position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 5',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[4].pk,
'position': 1,
'rear_port_position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 6',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[5].pk,
'position': 1,
'rear_port_position': 1,
},
]
@ -674,6 +692,9 @@ 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):
@ -711,6 +732,9 @@ 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):
@ -762,6 +786,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
'color': 'ffff00',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -791,6 +818,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
'slug': 'platform-6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -806,6 +836,9 @@ 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):
@ -902,26 +935,6 @@ 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.
@ -963,7 +976,10 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = ConsoleServerPort
@classmethod
@ -999,7 +1015,10 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = ConsolePort
@classmethod
@ -1035,7 +1054,10 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = PowerOutlet
@classmethod
@ -1071,7 +1093,10 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = PowerPort
@classmethod
@ -1107,7 +1132,10 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface
@classmethod
@ -1159,30 +1187,13 @@ 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)
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):
class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface
@classmethod
@ -1235,9 +1246,12 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
]
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface
@classmethod
@ -1277,6 +1291,9 @@ class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase)
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
brief_fields = ['device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -1336,7 +1353,10 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
model = InventoryItem
brief_fields = ['device', 'id', 'name', 'url']
brief_fields = ['_depth', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@ -1346,12 +1366,9 @@ 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)
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)
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)
cls.create_data = [
{
@ -1375,6 +1392,10 @@ 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
@ -1431,377 +1452,6 @@ 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):
@ -1888,10 +1538,13 @@ 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)
@ -1916,6 +1569,10 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
},
]
cls.bulk_update_data = {
'domain': 'newdomain',
}
class PowerPanelTest(APIViewTestCases.APIViewTestCase):
model = PowerPanel
@ -1923,43 +1580,55 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
sites = (
Site.objects.create(name='Site 1', slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
rack_groups = (
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),
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]),
)
power_panels = (
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(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.objects.bulk_create(power_panels)
cls.create_data = [
{
'name': 'Power Panel 4',
'site': site.pk,
'site': sites[0].pk,
'rack_group': rack_groups[0].pk,
},
{
'name': 'Power Panel 5',
'site': site.pk,
'site': sites[0].pk,
'rack_group': rack_groups[1].pk,
},
{
'name': 'Power Panel 6',
'site': site.pk,
'site': sites[0].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):

View File

@ -0,0 +1,923 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from circuits.models import *
from dcim.choices import CableStatusChoices
from dcim.models import *
from dcim.utils import object_to_path_node
class CablePathTestCase(TestCase):
"""
Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered
as follows:
1XX: Test direct connections between different endpoint types
2XX: Test different cable topologies
3XX: Test responses to changes in existing objects
"""
@classmethod
def setUpTestData(cls):
# Create a single device that will hold all components
cls.site = Site.objects.create(name='Site', slug='site')
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
cls.device = Device.objects.create(site=cls.site, device_type=device_type, device_role=device_role, name='Test Device')
cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel')
provider = Provider.objects.create(name='Provider', slug='provider')
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists.
:param origin: Originating endpoint
:param destination: Terminating endpoint, or None
:param path: Sequence of objects comprising the intermediate path (optional)
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
:param msg: Custom failure message (optional)
:return: The matching CablePath (if any)
"""
kwargs = {
'origin_type': ContentType.objects.get_for_model(origin),
'origin_id': origin.pk,
}
if destination is not None:
kwargs['destination_type'] = ContentType.objects.get_for_model(destination)
kwargs['destination_id'] = destination.pk
else:
kwargs['destination_type__isnull'] = True
kwargs['destination_id__isnull'] = True
if path is not None:
kwargs['path'] = [object_to_path_node(obj) for obj in path]
if is_active is not None:
kwargs['is_active'] = is_active
if msg is None:
if destination is not None:
msg = f"Missing path from {origin} to {destination}"
else:
msg = f"Missing partial path originating from {origin}"
cablepath = CablePath.objects.filter(**kwargs).first()
self.assertIsNotNone(cablepath, msg=msg)
return cablepath
def assertPathIsSet(self, origin, cablepath, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param cablepath: The CablePath instance originating from this endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}"
self.assertEqual(origin._path_id, cablepath.pk, msg=msg)
def assertPathIsNotSet(self, origin, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
self.assertIsNone(origin._path_id, msg=msg)
def test_101_interface_to_interface(self):
"""
[IF1] --C1-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=interface2)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(interface2, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_102_consoleport_to_consoleserverport(self):
"""
[CP1] --C1-- [CSP1]
"""
consoleport1 = ConsolePort.objects.create(device=self.device, name='Console Port 1')
consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Console Server Port 1')
# Create cable 1
cable1 = Cable(termination_a=consoleport1, termination_b=consoleserverport1)
cable1.save()
path1 = self.assertPathExists(
origin=consoleport1,
destination=consoleserverport1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=consoleserverport1,
destination=consoleport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
consoleport1.refresh_from_db()
consoleserverport1.refresh_from_db()
self.assertPathIsSet(consoleport1, path1)
self.assertPathIsSet(consoleserverport1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_103_powerport_to_poweroutlet(self):
"""
[PP1] --C1-- [PO1]
"""
powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1')
poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Power Outlet 1')
# Create cable 1
cable1 = Cable(termination_a=powerport1, termination_b=poweroutlet1)
cable1.save()
path1 = self.assertPathExists(
origin=powerport1,
destination=poweroutlet1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=poweroutlet1,
destination=powerport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
powerport1.refresh_from_db()
poweroutlet1.refresh_from_db()
self.assertPathIsSet(powerport1, path1)
self.assertPathIsSet(poweroutlet1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_104_powerport_to_powerfeed(self):
"""
[PP1] --C1-- [PF1]
"""
powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1')
powerfeed1 = PowerFeed.objects.create(power_panel=self.powerpanel, name='Power Feed 1')
# Create cable 1
cable1 = Cable(termination_a=powerport1, termination_b=powerfeed1)
cable1.save()
path1 = self.assertPathExists(
origin=powerport1,
destination=powerfeed1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=powerfeed1,
destination=powerport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
powerport1.refresh_from_db()
powerfeed1.refresh_from_db()
self.assertPathIsSet(powerport1, path1)
self.assertPathIsSet(powerfeed1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_105_interface_to_circuittermination(self):
"""
[IF1] --C1-- [CT1A]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=circuittermination1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=circuittermination1,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
circuittermination1.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(circuittermination1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_201_single_path_via_pass_through(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 2
cable2 = Cable(termination_a=rearport1, termination_b=interface2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsNotSet(interface2)
def test_202_multiple_paths_via_pass_through(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
# Create cables 1-2
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1_1, rearport1),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_2, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(termination_a=rearport1, termination_b=rearport2)
cable3.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cables 4-5
cable4 = Cable(termination_a=frontport2_1, termination_b=interface3)
cable4.save()
cable5 = Cable(termination_a=frontport2_2, termination_b=interface4)
cable5.save()
path1 = self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1,
cable4,
),
is_active=True
)
path2 = self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2,
cable5,
),
is_active=True
)
path3 = self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
path4 = self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
interface1.refresh_from_db()
interface2.refresh_from_db()
interface3.refresh_from_db()
interface4.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(interface2, path2)
self.assertPathIsSet(interface3, path3)
self.assertPathIsSet(interface4, path4)
def test_203_multiple_paths_via_nested_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3] --C5-- [RP4] [FP4:1] --C6-- [IF3]
[IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4_1 = FrontPort.objects.create(
device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
)
frontport4_2 = FrontPort.objects.create(
device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
)
# Create cables 1-2, 6-7
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
cable6 = Cable(termination_a=interface3, termination_b=frontport4_1)
cable6.save()
cable7 = Cable(termination_a=interface4, termination_b=frontport4_2)
cable7.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 3 and 5
cable3 = Cable(termination_a=rearport1, termination_b=frontport2)
cable3.save()
cable5 = Cable(termination_a=rearport4, termination_b=frontport3)
cable5.save()
self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface
# Create cable 4
cable4 = Cable(termination_a=rearport2, termination_b=rearport3)
cable4.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3, cable5, rearport4, frontport4_1,
cable6
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3, cable5, rearport4, frontport4_2,
cable7
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable6, frontport4_1, rearport4, cable5, frontport3, rearport3,
cable4, rearport2, frontport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable7, frontport4_2, rearport4, cable5, frontport3, rearport3,
cable4, rearport2, frontport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_204_multiple_paths_via_multiple_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
frontport3_1 = FrontPort.objects.create(
device=self.device, name='Front Port 3:1', rear_port=rearport3, rear_port_position=1
)
frontport3_2 = FrontPort.objects.create(
device=self.device, name='Front Port 3:2', rear_port=rearport3, rear_port_position=2
)
frontport4_1 = FrontPort.objects.create(
device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
)
frontport4_2 = FrontPort.objects.create(
device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
)
# Create cables 1-3, 6-8
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
cable3 = Cable(termination_a=rearport1, termination_b=rearport2)
cable3.save()
cable6 = Cable(termination_a=rearport3, termination_b=rearport4)
cable6.save()
cable7 = Cable(termination_a=interface3, termination_b=frontport4_1)
cable7.save()
cable8 = Cable(termination_a=interface4, termination_b=frontport4_2)
cable8.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 4 and 5
cable4 = Cable(termination_a=frontport2_1, termination_b=frontport3_1)
cable4.save()
cable5 = Cable(termination_a=frontport2_2, termination_b=frontport3_2)
cable5.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1,
cable4, frontport3_1, rearport3, cable6, rearport4, frontport4_1,
cable7
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2,
cable5, frontport3_2, rearport3, cable6, rearport4, frontport4_2,
cable8
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1,
cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2,
cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 5
cable5.delete()
# Check for two complete paths (IF1 <--> IF2) and two partial (IF3 <--> IF4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2)
def test_205_multiple_paths_via_patched_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3:1] --C5-- [IF3]
[IF2] --C2-- [FP1:2] [FP3:2] --C6-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 5', rear_port=rearport2, rear_port_position=1
)
frontport3_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport3, rear_port_position=1
)
frontport3_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport3, rear_port_position=2
)
# Create cables 1-2, 5-6
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2
cable2.save()
cable5 = Cable(termination_a=interface3, termination_b=frontport3_1) # IF3 -> FP3:1
cable5.save()
cable6 = Cable(termination_a=interface4, termination_b=frontport3_2) # IF4 -> FP3:2
cable6.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 3-4
cable3 = Cable(termination_a=rearport1, termination_b=frontport2) # RP1 -> FP2
cable3.save()
cable4 = Cable(termination_a=rearport2, termination_b=rearport3) # RP2 -> RP3
cable4.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3_1, cable5
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3_2, cable6
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable5, frontport3_1, rearport3, cable4, rearport2, frontport2,
cable3, rearport1, frontport1_1, cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable6, frontport3_2, rearport3, cable4, rearport2, frontport2,
cable3, rearport1, frontport1_2, cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_206_unidirectional_split_paths(self):
"""
[IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2]
[FP1:2] --C3-- [IF3]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
# Create cables 1
cable1 = Cable(termination_a=interface1, termination_b=rearport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cables 2-3
cable2 = Cable(termination_a=interface2, termination_b=frontport1_1)
cable2.save()
cable3 = Cable(termination_a=interface3, termination_b=frontport1_2)
cable3.save()
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, frontport1_1, rearport1, cable1),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(cable3, frontport1_2, rearport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
# Delete cable 1
cable1.delete()
# Check that the partial path was deleted and the two complete paths are now partial
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_1, rearport1),
is_active=False
)
self.assertPathExists(
origin=interface3,
destination=None,
path=(cable3, frontport1_2, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
def test_207_rearport_without_frontport(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1, cable2, rearport2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
# Create cable 2
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
cable2.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 3
cable3 = Cable(termination_a=frontport2, termination_b=interface2)
cable3.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_302_update_path_on_cable_status_change(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cables 1 and 2
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
cable2 = Cable(termination_a=rearport1, termination_b=interface2)
cable2.save()
self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2)
self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "planned"
cable2.status = CableStatusChoices.STATUS_PLANNED
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "connected"
cable2 = Cable.objects.get(pk=cable2.pk)
cable2.status = CableStatusChoices.STATUS_CONNECTED
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)

View File

@ -329,7 +329,7 @@ class RackTestCase(TestCase):
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
)
Rack.objects.bulk_create(racks)
@ -351,13 +351,11 @@ class RackTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Test for multiple values
params = {'type': RackTypeChoices.TYPE_2POST}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):
# TODO: Test for multiple values
params = {'width': RackWidthChoices.WIDTH_19IN}
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_u_height(self):
@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
users = User.objects.all()[:2]
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Filtering by username is broken
# params = {'user': [users[0].username, users[1].username]}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
@ -1514,10 +1511,11 @@ class ConsolePortTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value
def test_connection_status(self):
params = {'connection_status': 'True'}
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(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
@ -1609,10 +1607,11 @@ class ConsoleServerPortTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value
def test_connection_status(self):
params = {'connection_status': 'True'}
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(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
@ -1712,10 +1711,11 @@ class PowerPortTestCase(TestCase):
params = {'allocated_draw': [50, 100]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value
def test_connection_status(self):
params = {'connection_status': 'True'}
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(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
@ -1812,10 +1812,11 @@ class PowerOutletTestCase(TestCase):
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# TODO: Fix boolean value
def test_connection_status(self):
params = {'connection_status': 'True'}
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(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
@ -1900,10 +1901,11 @@ class InterfaceTestCase(TestCase):
params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value
def test_connection_status(self):
params = {'connection_status': 'True'}
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': 'true'}
@ -2285,14 +2287,16 @@ class InventoryItemTestCase(TestCase):
InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
)
InventoryItem.objects.bulk_create(inventory_items)
for i in inventory_items:
i.save()
child_inventory_items = (
InventoryItem(device=devices[0], name='Inventory Item 1A', parent=inventory_items[0]),
InventoryItem(device=devices[1], name='Inventory Item 2A', parent=inventory_items[1]),
InventoryItem(device=devices[2], name='Inventory Item 3A', parent=inventory_items[2]),
)
InventoryItem.objects.bulk_create(child_inventory_items)
for i in child_inventory_items:
i.save()
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
@ -2660,6 +2664,18 @@ class PowerFeedTestCase(TestCase):
)
PowerFeed.objects.bulk_create(power_feeds)
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
device = Device.objects.create(name='Device', device_type=device_type, device_role=device_role, site=sites[0])
power_ports = [
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
]
PowerPort.objects.bulk_create(power_ports)
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2721,5 +2737,17 @@ class PowerFeedTestCase(TestCase):
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
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(), 1)
# TODO: Connection filters

View File

@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_non_racked_device_with_face_position(self):
def test_non_racked_device_with_face(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
'site': Site.objects.first().pk,
'rack': None,
'face': DeviceFaceChoices.FACE_REAR,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('face', form.errors)
def test_non_racked_device_with_position(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('position', form.errors)

View File

@ -7,6 +7,42 @@ from dcim.models import *
from tenancy.models import Tenant
class RackGroupTestCase(TestCase):
def test_change_rackgroup_site(self):
"""
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
Site A
- RackGroup A1
- RackGroup A2
- Rack 2
- Rack 1
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
rackgroup_a1.save()
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
rackgroup_a2.save()
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
# Move RackGroup A1 to Site B
rackgroup_a1.site = site_b
rackgroup_a1.save()
# Check that all objects within RackGroup A1 now belong to Site B
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
class RackTestCase(TestCase):
def setUp(self):
@ -154,6 +190,34 @@ class RackTestCase(TestCase):
)
self.assertTrue(pdu)
def test_change_rack_site(self):
"""
Check that child Devices get updated when a Rack is moved to a new Site.
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
# Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
# Move Rack1 to Site B
rack1.site = site_b
rack1.save()
# Check that Device1 is now assigned to Site B
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
class DeviceTestCase(TestCase):
@ -390,17 +454,19 @@ class CableTestCase(TestCase):
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000)
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000)
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A')
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z')
def test_cable_creation(self):
"""
When a new Cable is created, it must be cached on either termination point.
"""
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(self.cable.termination_a, interface1)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1)
self.assertEqual(interface1._cable_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2)
self.assertEqual(interface2._cable_peer, interface1)
def test_cable_deletion(self):
"""
@ -412,8 +478,10 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._cable_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable)
self.assertIsNone(interface2._cable_peer)
def test_cabletermination_deletion(self):
"""
@ -457,21 +525,9 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_connection_via_single_position_rearport(self):
def test_rearport_connections(self):
"""
A RearPort with one position can be connected to anything.
[CableTermination X]---[RP(pos=1) FP]---[CableTermination Y]
is allowed anywhere
[CableTermination X]---[CableTermination Y]
is allowed.
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions. RearPorts with a single position on the other hand may be connected
to such CableTerminations. Check that this is indeed allowed.
Test various combinations of RearPort connections.
"""
# Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
@ -482,68 +538,22 @@ class CableTestCase(TestCase):
# Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
def test_connection_via_multi_position_rearport(self):
"""
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions.
The following scenario's are allowed (with x>1):
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=x)
| |
~----------+ +---------~
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=1)
| |
~----------+ +---------~
~----------+ +------------------~
| |
RP2(pos=x)|---|CircuitTermination
| |
~----------+ +------------------~
These scenarios are NOT allowed (with x>1):
~----------+ +----------~
| |
RP2(pos=x)|---|RP(pos!=x)
| |
~----------+ +----------~
~----------+ +----------~
| |
RP2(pos=x)|---|Interface
| |
~----------+ +----------~
These scenarios are tested in this order below.
"""
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to a single-position RearPort is ok
Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean()
# Connecting a multi-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
# Connecting a two-position RearPort to a three-position RearPort is NOT ok
with self.assertRaises(
ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a multi-position RearPort to an Interface should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self):
"""
A cable cannot terminate to a virtual interface
@ -561,628 +571,3 @@ class CableTestCase(TestCase):
cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
with self.assertRaises(ValidationError):
cable.clean()
class CablePathTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000),
CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000),
))
# Create four network devices with four interfaces each
devices = (
Device(device_type=devicetype, device_role=devicerole, name='Device 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 4', site=site),
)
Device.objects.bulk_create(devices)
for device in devices:
Interface.objects.bulk_create((
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
))
# Create four patch panels, each with one rear port and four front ports
patch_panels = (
Device(device_type=devicetype, device_role=devicerole, name='Panel 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site),
)
Device.objects.bulk_create(patch_panels)
# Create patch panels with 4 positions
for patch_panel in patch_panels[:4]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.bulk_create((
FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 2', rear_port=rearport, rear_port_position=2, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 3', rear_port=rearport, rear_port_position=3, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
))
# Create 1-on-1 patch panels
for patch_panel in patch_panels[4:]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C)
def test_direct_connection(self):
"""
Test a direct connection between two interfaces.
[Device 1] ----- [Device 2]
Iface1 Iface1
"""
# Create cable
cable = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable.full_clean()
cable.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable
cable.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_single_rear_port(self):
"""
Test a connection which passes through a rear port with exactly one front port.
1 2
[Device 1] ----- [Panel 5] ----- [Device 2]
Iface1 FP1 RP1 Iface1
"""
# Create cables (FP first, RP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
self.assertEqual(cable2.termination_a.positions, 1) # Sanity check
cable2.full_clean()
cable2.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable 1
cable1.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connections_via_nested_single_position_rearport(self):
"""
Test a connection which passes through a single front/rear port pair between two multi-position rear ports.
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 | FP1
[Panel 1] ----- [Panel 5] ----- [Panel 2]
FP2 | RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
5 6
"""
# Create cables (Panel 5 RP first, FP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable6.full_clean()
cable6.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_patch(self):
"""
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 | FP1
[Panel 1] ----- [Panel 2]
FP2 | RP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
4 5
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
)
cable5.full_clean()
cable5.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_multiple_patches(self):
"""
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2 3
[Device 1] -----------+ +---------------+ +----------- [Device 2]
Iface1 | | | | Iface1
FP1 | 4 | FP1 FP1 | 5 | FP1
[Panel 1] ----- [Panel 2] [Panel 3] ----- [Panel 4]
FP2 | RP1 RP1 | FP2 FP2 | RP1 RP1 | FP2
Iface1 | | | | Iface1
[Device 3] -----------+ +---------------+ +----------- [Device 4]
6 7 8
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
)
cable7.full_clean()
cable7.save()
cable8 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable8.full_clean()
cable8.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cables 4 and 5
cable4.delete()
cable5.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_nested_rear_ports(self):
"""
Test two connections via nested rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 5 | FP1
[Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4]
FP2 | RP1 FP1 RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
6 7
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable7.full_clean()
cable7.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 4
cable4.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connection_via_circuit(self):
"""
1 2
[Device 1] ----- [Circuit] ----- [Device 2]
Iface1 A Z Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_patched_circuit(self):
"""
1 2 3 4
[Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2]
Iface1 FP1 RP1 A Z RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable4.full_clean()
cable4.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)

View File

@ -101,10 +101,10 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,slug",
"Site 4,site-4",
"Site 5,site-5",
"Site 6,site-6",
"name,slug,status",
"Site 4,site-4,planned",
"Site 5,site-5,active",
"Site 6,site-6,staging",
)
cls.bulk_edit_data = {
@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_rack_elevations(self):
"""
Test viewing the list of rack elevations.
"""
response = self.client.get(reverse('dcim:rack_elevation_list'))
self.assertHttpStatus(response, 200)
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer
@ -983,9 +991,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.csv_data = (
"device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front",
)
cls.bulk_edit_data = {
@ -997,6 +1005,130 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_consoleports(self):
device = Device.objects.first()
console_ports = (
ConsolePort(device=device, name='Console Port 1'),
ConsolePort(device=device, name='Console Port 2'),
ConsolePort(device=device, name='Console Port 3'),
)
ConsolePort.objects.bulk_create(console_ports)
url = reverse('dcim:device_consoleports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_consoleserverports(self):
device = Device.objects.first()
console_server_ports = (
ConsoleServerPort(device=device, name='Console Server Port 1'),
ConsoleServerPort(device=device, name='Console Server Port 2'),
ConsoleServerPort(device=device, name='Console Server Port 3'),
)
ConsoleServerPort.objects.bulk_create(console_server_ports)
url = reverse('dcim:device_consoleserverports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_powerports(self):
device = Device.objects.first()
power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
PowerPort(device=device, name='Power Port 3'),
)
PowerPort.objects.bulk_create(power_ports)
url = reverse('dcim:device_powerports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_poweroutlets(self):
device = Device.objects.first()
power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1'),
PowerOutlet(device=device, name='Power Outlet 2'),
PowerOutlet(device=device, name='Power Outlet 3'),
)
PowerOutlet.objects.bulk_create(power_outlets)
url = reverse('dcim:device_poweroutlets', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_interfaces(self):
device = Device.objects.first()
interfaces = (
Interface(device=device, name='Interface 1'),
Interface(device=device, name='Interface 2'),
Interface(device=device, name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
url = reverse('dcim:device_interfaces', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_rearports(self):
device = Device.objects.first()
rear_ports = (
RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'),
)
RearPort.objects.bulk_create(rear_ports)
url = reverse('dcim:device_rearports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_frontports(self):
device = Device.objects.first()
rear_ports = (
RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'),
)
RearPort.objects.bulk_create(rear_ports)
front_ports = (
FrontPort(device=device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
FrontPort(device=device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
FrontPort(device=device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
)
FrontPort.objects.bulk_create(front_ports)
url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_devicebays(self):
device = Device.objects.first()
device_bays = (
DeviceBay(device=device, name='Device Bay 1'),
DeviceBay(device=device, name='Device Bay 2'),
DeviceBay(device=device, name='Device Bay 3'),
)
DeviceBay.objects.bulk_create(device_bays)
url = reverse('dcim:device_devicebays', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_inventory(self):
device = Device.objects.first()
inventory_items = (
InventoryItem(device=device, name='Inventory Item 1'),
InventoryItem(device=device, name='Inventory Item 2'),
InventoryItem(device=device, name='Inventory Item 3'),
)
for item in inventory_items:
item.save()
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
@ -1267,9 +1399,9 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = (
"device,name,type",
"Device 1,Interface 4,1000BASE-T (1GE)",
"Device 1,Interface 5,1000BASE-T (1GE)",
"Device 1,Interface 6,1000BASE-T (1GE)",
"Device 1,Interface 4,1000base-t",
"Device 1,Interface 5,1000base-t",
"Device 1,Interface 6,1000base-t",
)
@ -1326,9 +1458,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = (
"device,name,type,rear_port,rear_port_position",
"Device 1,Front Port 4,8P8C,Rear Port 4,1",
"Device 1,Front Port 5,8P8C,Rear Port 5,1",
"Device 1,Front Port 6,8P8C,Rear Port 6,1",
"Device 1,Front Port 4,8p8c,Rear Port 4,1",
"Device 1,Front Port 5,8p8c,Rear Port 5,1",
"Device 1,Front Port 6,8p8c,Rear Port 6,1",
)
@ -1372,9 +1504,9 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = (
"device,name,type,positions",
"Device 1,Rear Port 4,8P8C,1",
"Device 1,Rear Port 5,8P8C,1",
"Device 1,Rear Port 6,8P8C,1",
"Device 1,Rear Port 4,8p8c,1",
"Device 1,Rear Port 5,8p8c,1",
"Device 1,Rear Port 6,8p8c,1",
)
@ -1430,11 +1562,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
device = create_test_device('Device 1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
InventoryItem.objects.bulk_create([
InventoryItem(device=device, name='Inventory Item 1'),
InventoryItem(device=device, name='Inventory Item 2'),
InventoryItem(device=device, name='Inventory Item 3'),
])
InventoryItem.objects.create(device=device, name='Inventory Item 1')
InventoryItem.objects.create(device=device, name='Inventory Item 2')
InventoryItem.objects.create(device=device, name='Inventory Item 3')
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@ -1546,9 +1676,9 @@ class CableTestCase(
cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 3,interface,Interface 1,Device 4,interface,Interface 1",
"Device 3,interface,Interface 2,Device 4,interface,Interface 2",
"Device 3,interface,Interface 3,Device 4,interface,Interface 3",
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
)
cls.bulk_edit_data = {
@ -1716,11 +1846,6 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'max_utilization': 50,
'comments': 'New comments',
'tags': [t.pk for t in tags],
# Connection
'cable': None,
'connected_endpoint': None,
'connection_status': None,
}
cls.csv_data = (

View File

@ -187,9 +187,17 @@ urlpatterns = [
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
path('devices/<int:pk>/console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'),
path('devices/<int:pk>/power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'),
path('devices/<int:pk>/power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'),
path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
@ -208,7 +216,7 @@ urlpatterns = [
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
@ -224,7 +232,7 @@ urlpatterns = [
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
@ -240,7 +248,7 @@ urlpatterns = [
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
@ -256,7 +264,7 @@ urlpatterns = [
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
@ -272,7 +280,7 @@ urlpatterns = [
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@ -288,7 +296,7 @@ urlpatterns = [
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
@ -304,7 +312,7 @@ urlpatterns = [
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
@ -384,6 +392,8 @@ urlpatterns = [
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
]

Some files were not shown because too many files have changed in this diff Show More