mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-31 14:18:15 +01:00
Compare commits
92 Commits
v3.2-beta1
...
v3.2-beta2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cffa6ebcc | ||
|
|
f559ceeb7f | ||
|
|
e36ae4f0f7 | ||
|
|
bbdeae0ed9 | ||
|
|
efd5a73a18 | ||
|
|
a11abf87ec | ||
|
|
28f7b411ed | ||
|
|
655bc49fad | ||
|
|
18c9ee2f9d | ||
|
|
07f2cdac58 | ||
|
|
06781beb81 | ||
|
|
cd29293dd6 | ||
|
|
1fdc7a9163 | ||
|
|
6807db4967 | ||
|
|
b0ea416d6d | ||
|
|
c515218760 | ||
|
|
8053ea0a22 | ||
|
|
a5603c9953 | ||
|
|
bffe63a233 | ||
|
|
2cfbfe473e | ||
|
|
3c78c100b5 | ||
|
|
2451b0a5b1 | ||
|
|
85e9438ff7 | ||
|
|
81610ba86e | ||
|
|
6423b386d2 | ||
|
|
5c48d116eb | ||
|
|
90257e9dee | ||
|
|
a740203444 | ||
|
|
25dc9cc14a | ||
|
|
d4d2af46ac | ||
|
|
64acfc3187 | ||
|
|
62a1d4b3e0 | ||
|
|
e6072a51f8 | ||
|
|
21e3159711 | ||
|
|
dadd8cb93a | ||
|
|
5f8af6ad66 | ||
|
|
3436905744 | ||
|
|
e3258bcf5a | ||
|
|
2b6e0405a5 | ||
|
|
7f752d9102 | ||
|
|
df430394b0 | ||
|
|
638d89e73b | ||
|
|
1ab51ca04e | ||
|
|
cb0386779c | ||
|
|
4dc428d75b | ||
|
|
e7496c8840 | ||
|
|
d6c272cfd6 | ||
|
|
da6ed8ea11 | ||
|
|
28de330b50 | ||
|
|
7823fa05ef | ||
|
|
fab4d95156 | ||
|
|
06cb7f35f1 | ||
|
|
796c5d785e | ||
|
|
c88db77814 | ||
|
|
6fe0f4cd7d | ||
|
|
3bf90c3c38 | ||
|
|
992f3535b7 | ||
|
|
06eacb5a5c | ||
|
|
c0152ce52f | ||
|
|
5a60224d77 | ||
|
|
c137fa2022 | ||
|
|
879d01a750 | ||
|
|
6db878743c | ||
|
|
08b90090f5 | ||
|
|
42466d5fc4 | ||
|
|
0953bba0a3 | ||
|
|
4863591bc8 | ||
|
|
c489501441 | ||
|
|
6638f560f8 | ||
|
|
a2fe23549b | ||
|
|
1a7438acfd | ||
|
|
b1de85a44f | ||
|
|
6604ebfd01 | ||
|
|
7872460162 | ||
|
|
d9696ae34c | ||
|
|
7c937bf8b8 | ||
|
|
4913d7ee39 | ||
|
|
2503a3e3ca | ||
|
|
5f92ed492b | ||
|
|
bca0978434 | ||
|
|
e1f06ec862 | ||
|
|
a5c8bbf79e | ||
|
|
e8df373abf | ||
|
|
39b8eb0ae0 | ||
|
|
eb02f6137e | ||
|
|
90ee689d5a | ||
|
|
90558a2e80 | ||
|
|
b343035060 | ||
|
|
bcdd006dd5 | ||
|
|
92c4e5bfaf | ||
|
|
fdbdf26afc | ||
|
|
6bbf168cec |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.8
|
||||
placeholder: v3.1.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.8
|
||||
placeholder: v3.1.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
</div>
|
||||
|
||||
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
|
||||
|
||||

|
||||
|
||||
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
|
||||
|
||||
@@ -117,3 +117,7 @@ svgwrite
|
||||
# Tabular dataset library (for table-based exports)
|
||||
# https://github.com/jazzband/tablib
|
||||
tablib
|
||||
|
||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||
# https://github.com/python/tzdata
|
||||
tzdata
|
||||
|
||||
@@ -11,17 +11,25 @@ Getting started with NetBox development is pretty straightforward, and should fe
|
||||
|
||||
### 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:
|
||||
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.) Click the "fork" button at top right (be sure that you've logged into GitHub first).
|
||||
|
||||

|
||||
|
||||
Copy the URL provided in the dialog box.
|
||||
|
||||

|
||||
|
||||
You can then clone your GitHub fork locally for development:
|
||||
|
||||
```no-highlight
|
||||
$ git clone https://github.com/youruseraccount/netbox.git
|
||||
$ git clone https://github.com/$username/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.
|
||||
remote: Enumerating objects: 85949, done.
|
||||
remote: Counting objects: 100% (4672/4672), done.
|
||||
remote: Compressing objects: 100% (1224/1224), done.
|
||||
remote: Total 85949 (delta 3538), reused 4332 (delta 3438), pack-reused 81277
|
||||
Receiving objects: 100% (85949/85949), 55.16 MiB | 44.90 MiB/s, done.
|
||||
Resolving deltas: 100% (68008/68008), 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
|
||||
@@ -33,7 +41,7 @@ The NetBox project utilizes three persistent git branches to track work:
|
||||
* `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.
|
||||
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: This branch only ever merges pull requests from the `develop` branch, to effect a new release.
|
||||
|
||||
For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0).
|
||||
|
||||
@@ -60,7 +68,7 @@ $ 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 "Where to Create Your Virtual Environments"
|
||||
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please.
|
||||
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple venvs.
|
||||
|
||||
Once created, activate the virtual environment:
|
||||
|
||||
@@ -99,12 +107,13 @@ Within the `netbox/netbox/` directory, copy `configuration_example.py` to `confi
|
||||
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command:
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py runserver
|
||||
$ ./manage.py runserver
|
||||
Watching for file changes with StatReloader
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
November 18, 2020 - 15:52:31
|
||||
Django version 3.1, using settings 'netbox.settings'
|
||||
February 18, 2022 - 20:29:57
|
||||
Django version 4.0.2, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
@@ -122,24 +131,36 @@ The demo data is provided in JSON format and loaded into an empty database using
|
||||
|
||||
## Running Tests
|
||||
|
||||
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
|
||||
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository.
|
||||
|
||||
To avoid potential issues with your local configuration file, set the `NETBOX_CONFIGURATION` to point to the packaged test configuration at `netbox/configuration_testing.py`. This will handle things like ensuring that the dummy plugin is enabled for comprehensive testing.
|
||||
|
||||
```no-highlight
|
||||
$ export NETBOX_CONFIGURATION=netbox.configuration_testing
|
||||
$ cd netbox/
|
||||
$ python 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.)
|
||||
In cases where you haven't made any changes to the database schema (which is typical), 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 manage.py test --keepdb
|
||||
```
|
||||
|
||||
You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
|
||||
You can also reduce testing time by enabling parallel test execution with the `--parallel` flag. (By default, this will run as many parallel tests as you have processors. To avoid sluggishness, it's a good idea to specify a lower number of parallel tests.) This flag can be combined with `--keepdb`, although if you encounter any strange errors, try running the test suite again with parallelization disabled.
|
||||
|
||||
```no-highlight
|
||||
$ python manage.py test --parallel <n>
|
||||
```
|
||||
|
||||
Finally, it's possible to limit the run to a specific set of tests, specified by their Python path. For example, to run only IPAM and DCIM view tests:
|
||||
|
||||
```no-highlight
|
||||
$ python manage.py test dcim.tests.test_views ipam.tests.test_views
|
||||
```
|
||||
|
||||
This is handy for instances where just a few tests are failing and you want to re-run them individually.
|
||||
|
||||
## 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 prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.
|
||||
|
||||
@@ -8,7 +8,7 @@ Check `base_requirements.txt` for any dependencies pinned to a specific version,
|
||||
|
||||
### Link to the Release Notes Page
|
||||
|
||||
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.
|
||||
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and add a summary of the major changes to `index.md`.
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
|
||||
|
||||
# What is NetBox?
|
||||
|
||||
NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management:
|
||||
|
||||
BIN
docs/media/development/github_fork_button.png
Normal file
BIN
docs/media/development/github_fork_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
docs/media/development/github_fork_dialog.png
Normal file
BIN
docs/media/development/github_fork_dialog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
@@ -24,13 +24,14 @@ Custom links appear as buttons in the top right corner of the page. Numeric weig
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `obj` | The NetBox object being displayed |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
| Variable | Description |
|
||||
|-----------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `object` | The NetBox object being displayed |
|
||||
| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
# Plugin Development
|
||||
|
||||
!!! info "Help Improve the NetBox Plugins Framework!"
|
||||
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
|
||||
|
||||
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
|
||||
|
||||
Plugins can do a lot, including:
|
||||
|
||||
* Create Django models to store data in the database
|
||||
* Provide their own "pages" (views) in the web user interface
|
||||
* Inject template content and navigation links
|
||||
* Establish their own REST API endpoints
|
||||
* Add custom request/response middleware
|
||||
|
||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||
|
||||
!!! warning
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
|
||||
|
||||
```no-highlight
|
||||
project-name/
|
||||
- plugin_name/
|
||||
- templates/
|
||||
- plugin_name/
|
||||
- *.html
|
||||
- __init__.py
|
||||
- middleware.py
|
||||
- navigation.py
|
||||
- signals.py
|
||||
- template_content.py
|
||||
- urls.py
|
||||
- views.py
|
||||
- README
|
||||
- setup.py
|
||||
```
|
||||
|
||||
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
|
||||
|
||||
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
|
||||
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
|
||||
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
|
||||
|
||||
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
|
||||
|
||||
### Create setup.py
|
||||
|
||||
`setup.py` is the [setup script](https://docs.python.org/3.7/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
|
||||
|
||||
```python
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name='netbox-animal-sounds',
|
||||
version='0.1',
|
||||
description='An example NetBox plugin',
|
||||
url='https://github.com/netbox-community/netbox-animal-sounds',
|
||||
author='Jeremy Stretch',
|
||||
license='Apache 2.0',
|
||||
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:
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginConfig
|
||||
|
||||
class AnimalSoundsConfig(PluginConfig):
|
||||
name = 'netbox_animal_sounds'
|
||||
verbose_name = 'Animal Sounds'
|
||||
description = 'An example plugin for development purposes'
|
||||
version = '0.1'
|
||||
author = 'Jeremy Stretch'
|
||||
author_email = 'author@example.com'
|
||||
base_url = 'animal-sounds'
|
||||
required_settings = []
|
||||
default_settings = {
|
||||
'loud': False
|
||||
}
|
||||
|
||||
config = AnimalSoundsConfig
|
||||
```
|
||||
|
||||
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
|
||||
|
||||
#### PluginConfig Attributes
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| `name` | Raw plugin name; same as the plugin's source directory |
|
||||
| `verbose_name` | Human-friendly name for the plugin |
|
||||
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
|
||||
| `description` | Brief description of the plugin's purpose |
|
||||
| `author` | Name of plugin's author |
|
||||
| `author_email` | Author's public email address |
|
||||
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
|
||||
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
|
||||
| `default_settings` | A dictionary of configuration parameters and their default values |
|
||||
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
||||
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
|
||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||
|
||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||
|
||||
### Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
```shell
|
||||
python3 -m venv /path/to/my/venv
|
||||
```
|
||||
|
||||
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
|
||||
|
||||
```shell
|
||||
cd $VENV/lib/python3.7/site-packages/
|
||||
echo /opt/netbox/netbox > netbox.pth
|
||||
```
|
||||
|
||||
### Install the Plugin for Development
|
||||
|
||||
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
||||
|
||||
```no-highlight
|
||||
$ python setup.py develop
|
||||
```
|
||||
|
||||
## Database Models
|
||||
|
||||
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
|
||||
|
||||
Below is an example `models.py` file containing a model with two character fields:
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class Animal(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
sound = models.CharField(max_length=50)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
|
||||
|
||||
!!! note
|
||||
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py makemigrations netbox_animal_sounds
|
||||
Migrations for 'netbox_animal_sounds':
|
||||
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
|
||||
- Create model Animal
|
||||
```
|
||||
|
||||
Next, we can apply the migration to the database with the `migrate` command:
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py migrate netbox_animal_sounds
|
||||
Operations to perform:
|
||||
Apply all migrations: netbox_animal_sounds
|
||||
Running migrations:
|
||||
Applying netbox_animal_sounds.0001_initial... OK
|
||||
```
|
||||
|
||||
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
|
||||
|
||||
### Using the Django Admin Interface
|
||||
|
||||
Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below:
|
||||
|
||||
```python
|
||||
from django.contrib import admin
|
||||
from .models import Animal
|
||||
|
||||
@admin.register(Animal)
|
||||
class AnimalAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'sound')
|
||||
```
|
||||
|
||||
This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view.
|
||||
|
||||

|
||||
|
||||
## Views
|
||||
|
||||
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
|
||||
|
||||
```python
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import View
|
||||
from .models import Animal
|
||||
|
||||
class RandomAnimalView(View):
|
||||
"""
|
||||
Display a randomly-selected animal.
|
||||
"""
|
||||
def get(self, request):
|
||||
animal = Animal.objects.order_by('?').first()
|
||||
return render(request, 'netbox_animal_sounds/animal.html', {
|
||||
'animal': animal,
|
||||
})
|
||||
```
|
||||
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
|
||||
|
||||
### Extending the Base Template
|
||||
|
||||
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
|
||||
|
||||
* `title` - The page title
|
||||
* `header` - The upper portion of the page
|
||||
* `content` - The main page body
|
||||
* `javascript` - A section at the end of the page for including Javascript code
|
||||
|
||||
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
|
||||
|
||||
```jinja2
|
||||
{% extends 'base/layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
<h2 class="text-center" style="margin-top: 200px">
|
||||
{% if animal %}
|
||||
The {{ animal.name|lower }} says
|
||||
{% if config.loud %}
|
||||
{{ animal.sound|upper }}!
|
||||
{% else %}
|
||||
{{ animal.sound }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No animals have been created yet!
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
```
|
||||
|
||||
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
|
||||
|
||||
!!! note
|
||||
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of.
|
||||
|
||||
Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('random/', views.RandomAnimalView.as_view(), name='random_animal'),
|
||||
]
|
||||
```
|
||||
|
||||
A URL pattern has three components:
|
||||
|
||||
* `route` - The unique portion of the URL dedicated to this view
|
||||
* `view` - The view itself
|
||||
* `name` - A short name used to identify the URL path internally
|
||||
|
||||
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
|
||||
|
||||
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
|
||||
|
||||
```python
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from netbox_animal_sounds.models import Animal
|
||||
|
||||
class AnimalSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Animal
|
||||
fields = ('id', 'name', 'sound')
|
||||
```
|
||||
|
||||
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
|
||||
|
||||
```python
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from netbox_animal_sounds.models import Animal
|
||||
from .serializers import AnimalSerializer
|
||||
|
||||
class AnimalViewSet(ModelViewSet):
|
||||
queryset = Animal.objects.all()
|
||||
serializer_class = AnimalSerializer
|
||||
```
|
||||
|
||||
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
|
||||
|
||||
```python
|
||||
from rest_framework import routers
|
||||
from .views import AnimalViewSet
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register('animals', AnimalViewSet)
|
||||
urlpatterns = router.urls
|
||||
```
|
||||
|
||||
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
|
||||
|
||||

|
||||
|
||||
!!! warning
|
||||
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.
|
||||
|
||||
## Navigation Menu Items
|
||||
|
||||
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginMenuButton, PluginMenuItem
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(
|
||||
link='plugins:netbox_animal_sounds:random_animal',
|
||||
link_text='Random sound',
|
||||
buttons=(
|
||||
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
|
||||
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
|
||||
)
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
* `link` - The name of the URL path to which this menu item links
|
||||
* `link_text` - The text presented to the user
|
||||
* `permissions` - A list of permissions required to display this link (optional)
|
||||
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
|
||||
|
||||
A `PluginMenuButton` has the following attributes:
|
||||
|
||||
* `link` - The name of the URL path to which this button links
|
||||
* `title` - The tooltip text (displayed when the mouse hovers over the button)
|
||||
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
|
||||
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
|
||||
* `permissions` - A list of permissions required to display this button (optional)
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
|
||||
## Extending Core Templates
|
||||
|
||||
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
|
||||
|
||||
* `left_page()` - Inject content on the left side of the page
|
||||
* `right_page()` - Inject content on the right side of the page
|
||||
* `full_width_page()` - Inject content across the entire bottom of the page
|
||||
* `buttons()` - Add buttons to the top of the page
|
||||
|
||||
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
|
||||
|
||||
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
|
||||
|
||||
* `object` - The object being viewed
|
||||
* `request` - The current request
|
||||
* `settings` - Global NetBox settings
|
||||
* `config` - Plugin-specific configuration parameters
|
||||
|
||||
For example, accessing `{{ request.user }}` within a template will return the current user.
|
||||
|
||||
Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below.
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginTemplateExtension
|
||||
from .models import Animal
|
||||
|
||||
class SiteAnimalCount(PluginTemplateExtension):
|
||||
model = 'dcim.site'
|
||||
|
||||
def right_page(self):
|
||||
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={
|
||||
'animal_count': Animal.objects.count(),
|
||||
})
|
||||
|
||||
template_extensions = [SiteAnimalCount]
|
||||
```
|
||||
|
||||
## Background Tasks
|
||||
|
||||
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
|
||||
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
|
||||
|
||||
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
|
||||
is below:
|
||||
|
||||
```python
|
||||
class MyPluginConfig(PluginConfig):
|
||||
name = 'myplugin'
|
||||
...
|
||||
queues = [
|
||||
'queue1',
|
||||
'queue2',
|
||||
'queue-whatever-the-name'
|
||||
]
|
||||
```
|
||||
|
||||
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
|
||||
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
|
||||
|
||||
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
|
||||
|
||||
```
|
||||
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
|
||||
```
|
||||
@@ -1,27 +1,30 @@
|
||||
# Background Tasks
|
||||
|
||||
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
|
||||
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
|
||||
NetBox supports the queuing of tasks that need to be performed in the background, decoupled from the request-response cycle, using the [Python RQ](https://python-rq.org/) library. Three task queues of differing priority are defined by default:
|
||||
|
||||
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
|
||||
is below:
|
||||
* High
|
||||
* Default
|
||||
* Low
|
||||
|
||||
Any tasks in the "high" queue are completed before the default queue is checked, and any tasks in the default queue are completed before those in the "low" queue.
|
||||
|
||||
Plugins can also add custom queues for their own needs by setting the `queues` attribute under the PluginConfig class. An example is included below:
|
||||
|
||||
```python
|
||||
class MyPluginConfig(PluginConfig):
|
||||
name = 'myplugin'
|
||||
...
|
||||
queues = [
|
||||
'queue1',
|
||||
'queue2',
|
||||
'queue-whatever-the-name'
|
||||
'foo',
|
||||
'bar',
|
||||
]
|
||||
```
|
||||
|
||||
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
|
||||
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
|
||||
The PluginConfig above creates two custom queues with the following names `my_plugin.foo` and `my_plugin.bar`. (The plugin's name is prepended to each queue to avoid conflicts between plugins.)
|
||||
|
||||
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
|
||||
|
||||
```
|
||||
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
|
||||
```
|
||||
!!! warning "Configuring the RQ worker process"
|
||||
By default, NetBox's RQ worker process only services the high, default, and low queues. Plugins which introduce custom queues should advise users to either reconfigure the default worker, or run a dedicated worker specifying the necessary queues. For example:
|
||||
|
||||
```
|
||||
python manage.py rqworker my_plugin.foo my_plugin.bar
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Filter Sets
|
||||
# Filters & Filter Sets
|
||||
|
||||
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
|
||||
|
||||
@@ -27,9 +27,9 @@ class MyFilterSet(NetBoxModelFilterSet):
|
||||
fields = ('some', 'other', 'fields')
|
||||
```
|
||||
|
||||
## Declaring Filter Sets
|
||||
### Declaring Filter Sets
|
||||
|
||||
To utilize a filter set in the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as the `filterset` attribute on the view class:
|
||||
To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class:
|
||||
|
||||
```python
|
||||
# views.py
|
||||
@@ -42,7 +42,7 @@ class MyModelListView(ObjectListView):
|
||||
filterset = MyModelFitlerSet
|
||||
```
|
||||
|
||||
To enable a filter on a REST API endpoint, set it as the `filterset_class` attribute on the API view:
|
||||
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
|
||||
|
||||
```python
|
||||
# api/views.py
|
||||
@@ -54,3 +54,17 @@ class MyModelViewSet(...):
|
||||
serializer_class = serializers.MyModelSerializer
|
||||
filterset_class = filtersets.MyModelFilterSet
|
||||
```
|
||||
|
||||
## Filter Classes
|
||||
|
||||
### TagFilter
|
||||
|
||||
The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
|
||||
|
||||
```python
|
||||
from django_filters import FilterSet
|
||||
from extras.filters import TagFilter
|
||||
|
||||
class MyModelFilterSet(FilterSet):
|
||||
tag = TagFilter()
|
||||
```
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Form Classes
|
||||
|
||||
NetBox provides several base form classes for use by plugins. These are documented below.
|
||||
NetBox provides several base form classes for use by plugins.
|
||||
|
||||
* `NetBoxModelForm`
|
||||
* `NetBoxModelCSVForm`
|
||||
* `NetBoxModelBulkEditForm`
|
||||
* `NetBoxModelFilterSetForm`
|
||||
|
||||
### TODO: Include forms reference
|
||||
<!-- TODO: Include forms reference -->
|
||||
|
||||
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
|
||||
|
||||
|
||||
@@ -33,14 +33,10 @@ NetBox provides two object type classes for use by plugins.
|
||||
::: netbox.graphql.types.BaseObjectType
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.graphql.types.NetBoxObjectType
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
## GraphQL Fields
|
||||
|
||||
@@ -49,11 +45,7 @@ NetBox provides two field classes for use by plugins.
|
||||
::: netbox.graphql.fields.ObjectField
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.graphql.fields.ObjectListField
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
@@ -1,106 +1,88 @@
|
||||
# Plugins Development
|
||||
|
||||
!!! info "Help Improve the NetBox Plugins Framework!"
|
||||
!!! tip "Help Improve the NetBox Plugins Framework!"
|
||||
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
|
||||
|
||||
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
|
||||
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
|
||||
|
||||
!!! info "Django Development"
|
||||
Django is the Python framework on which NetBox is built. As Django itself is very well-documented, this documentation covers only the aspects of plugin development which are unique to NetBox.
|
||||
|
||||
Plugins can do a lot, including:
|
||||
|
||||
* Create Django models to store data in the database
|
||||
* Provide their own "pages" (views) in the web user interface
|
||||
* Inject template content and navigation links
|
||||
* Establish their own REST API endpoints
|
||||
* Extend NetBox's REST and GraphQL APIs
|
||||
* Add custom request/response middleware
|
||||
|
||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||
|
||||
!!! warning
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework to avoid breaking changes in future releases.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### Plugin Structure
|
||||
## Plugin Structure
|
||||
|
||||
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look something like this:
|
||||
|
||||
```no-highlight
|
||||
project-name/
|
||||
- plugin_name/
|
||||
- api/
|
||||
- serializers.py
|
||||
- urls.py
|
||||
- views.py
|
||||
- templates/
|
||||
- plugin_name/
|
||||
- *.html
|
||||
- __init__.py
|
||||
- filtersets.py
|
||||
- models.py
|
||||
- middleware.py
|
||||
- navigation.py
|
||||
- signals.py
|
||||
- tables.py
|
||||
- template_content.py
|
||||
- urls.py
|
||||
- views.py
|
||||
- README
|
||||
- README.md
|
||||
- setup.py
|
||||
```
|
||||
|
||||
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
|
||||
|
||||
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
|
||||
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
|
||||
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
|
||||
* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display.
|
||||
* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores.
|
||||
|
||||
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
|
||||
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below.
|
||||
|
||||
### Create setup.py
|
||||
|
||||
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
|
||||
|
||||
```python
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name='netbox-animal-sounds',
|
||||
version='0.1',
|
||||
description='An example NetBox plugin',
|
||||
url='https://github.com/netbox-community/netbox-animal-sounds',
|
||||
author='Jeremy Stretch',
|
||||
license='Apache 2.0',
|
||||
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
|
||||
## 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:
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginConfig
|
||||
|
||||
class AnimalSoundsConfig(PluginConfig):
|
||||
name = 'netbox_animal_sounds'
|
||||
verbose_name = 'Animal Sounds'
|
||||
description = 'An example plugin for development purposes'
|
||||
class FooBarConfig(PluginConfig):
|
||||
name = 'foo_bar'
|
||||
verbose_name = 'Foo Bar'
|
||||
description = 'An example NetBox plugin'
|
||||
version = '0.1'
|
||||
author = 'Jeremy Stretch'
|
||||
author_email = 'author@example.com'
|
||||
base_url = 'animal-sounds'
|
||||
base_url = 'foo-bar'
|
||||
required_settings = []
|
||||
default_settings = {
|
||||
'loud': False
|
||||
'baz': True
|
||||
}
|
||||
|
||||
config = AnimalSoundsConfig
|
||||
config = FooBarConfig
|
||||
```
|
||||
|
||||
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
|
||||
|
||||
#### PluginConfig Attributes
|
||||
### PluginConfig Attributes
|
||||
|
||||
| Name | Description |
|
||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------------|
|
||||
@@ -116,6 +98,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
||||
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
|
||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||
| `queues` | A list of custom background task queues to create |
|
||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
|
||||
@@ -123,25 +106,60 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
|
||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||
|
||||
### Create a Virtual Environment
|
||||
## Create setup.py
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
|
||||
|
||||
```python
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name='my-example-plugin',
|
||||
version='0.1',
|
||||
description='An example NetBox plugin',
|
||||
url='https://github.com/jeremystretch/my-example-plugin',
|
||||
author='Jeremy Stretch',
|
||||
license='Apache 2.0',
|
||||
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).
|
||||
|
||||
!!! info
|
||||
`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)
|
||||
|
||||
## Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
```shell
|
||||
python3 -m venv /path/to/my/venv
|
||||
python3 -m venv ~/.virtualenvs/my_plugin
|
||||
```
|
||||
|
||||
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
|
||||
|
||||
```shell
|
||||
cd $VENV/lib/python3.8/site-packages/
|
||||
echo /opt/netbox/netbox > netbox.pth
|
||||
echo /opt/netbox/netbox > $VENV/lib/python3.8/site-packages/netbox.pth
|
||||
```
|
||||
|
||||
### Install the Plugin for Development
|
||||
## Development Installation
|
||||
|
||||
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
||||
|
||||
```no-highlight
|
||||
$ python setup.py develop
|
||||
```
|
||||
|
||||
## Configure NetBox
|
||||
|
||||
To enable the plugin in NetBox, add it to the `PLUGINS` parameter in `configuration.py`:
|
||||
|
||||
```python
|
||||
PLUGINS = [
|
||||
'my_plugin',
|
||||
]
|
||||
```
|
||||
|
||||
@@ -2,50 +2,26 @@
|
||||
|
||||
## Creating Models
|
||||
|
||||
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
|
||||
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Instances of a model (objects) can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
|
||||
|
||||
Below is an example `models.py` file containing a model with two character fields:
|
||||
Below is an example `models.py` file containing a model with two character (text) fields:
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class Animal(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
sound = models.CharField(max_length=50)
|
||||
class MyModel(models.Model):
|
||||
foo = models.CharField(max_length=50)
|
||||
bar = models.CharField(max_length=50)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return f'{self.foo} {self.bar}'
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
|
||||
|
||||
!!! note
|
||||
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py makemigrations netbox_animal_sounds
|
||||
Migrations for 'netbox_animal_sounds':
|
||||
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
|
||||
- Create model Animal
|
||||
```
|
||||
|
||||
Next, we can apply the migration to the database with the `migrate` command:
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py migrate netbox_animal_sounds
|
||||
Operations to perform:
|
||||
Apply all migrations: netbox_animal_sounds
|
||||
Running migrations:
|
||||
Applying netbox_animal_sounds.0001_initial... OK
|
||||
```
|
||||
|
||||
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
|
||||
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
||||
|
||||
## Enabling NetBox Features
|
||||
|
||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable numerous feature, including:
|
||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||
|
||||
* Change logging
|
||||
* Custom fields
|
||||
@@ -58,8 +34,8 @@ Plugin models can leverage certain NetBox features by inheriting from NetBox's `
|
||||
|
||||
This class performs two crucial functions:
|
||||
|
||||
1. Apply any fields, methods, or attributes necessary to the operation of these features
|
||||
2. Register the model with NetBox as utilizing these feature
|
||||
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
|
||||
2. Register the model with NetBox as utilizing these features
|
||||
|
||||
Simply subclass BaseModel when defining a model in your plugin:
|
||||
|
||||
@@ -75,7 +51,9 @@ class MyModel(NetBoxModel):
|
||||
|
||||
### Enabling Features Individually
|
||||
|
||||
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.)
|
||||
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
|
||||
|
||||
For example, if we wanted to support only tags and export templates, we would inherit from NetBox's `ExportTemplatesMixin` and `TagsMixin` classes, and from Django's `Model` class. (Inheriting _all_ the available mixins is essentially the same as subclassing `NetBoxModel`.)
|
||||
|
||||
```python
|
||||
# models.py
|
||||
@@ -87,11 +65,35 @@ class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
|
||||
...
|
||||
```
|
||||
|
||||
The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.)
|
||||
## Database Migrations
|
||||
|
||||
Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.)
|
||||
|
||||
!!! note Enable Developer Mode
|
||||
NetBox enforces a safeguard around the `makemigrations` command to protect regular users from inadvertently creating erroneous schema migrations. To enable this command for plugin development, set `DEVELOPER=True` in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py makemigrations my_plugin
|
||||
Migrations for 'my_plugin':
|
||||
/home/jstretch/animal_sounds/my_plugin/migrations/0001_initial.py
|
||||
- Create model MyModel
|
||||
```
|
||||
|
||||
Next, we can apply the migration to the database with the `migrate` command:
|
||||
|
||||
```no-highlight
|
||||
$ ./manage.py migrate my_plugin
|
||||
Operations to perform:
|
||||
Apply all migrations: my_plugin
|
||||
Running migrations:
|
||||
Applying my_plugin.0001_initial... OK
|
||||
```
|
||||
|
||||
For more information about database migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
|
||||
|
||||
## Feature Mixins Reference
|
||||
|
||||
!!! note
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
|
||||
|
||||
::: netbox.models.features.ChangeLoggingMixin
|
||||
@@ -109,3 +111,72 @@ The example above will enable export templates and tags, but no other NetBox fea
|
||||
::: netbox.models.features.TagsMixin
|
||||
|
||||
::: netbox.models.features.WebhooksMixin
|
||||
|
||||
## Choice Sets
|
||||
|
||||
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)
|
||||
|
||||
To define choices for a model field, subclass `ChoiceSet` and define a tuple named `CHOICES`, of which each member is a two- or three-element tuple. These elements are:
|
||||
|
||||
* The database value
|
||||
* The corresponding human-friendly label
|
||||
* The assigned color (optional)
|
||||
|
||||
A complete example is provided below.
|
||||
|
||||
!!! note
|
||||
Authors may find it useful to declare each of the database values as constants on the class, and reference them within `CHOICES` members. This convention allows the values to be referenced from outside the class, however it is not strictly required.
|
||||
|
||||
### Dynamic Configuration
|
||||
|
||||
Some model field choices in NetBox can be configured by an administrator. For example, the default values for the Site model's `status` field can be replaced or supplemented with custom choices. To enable dynamic configuration for a ChoiceSet subclass, define its `key` as a string specifying the model and field name to which it applies. For example:
|
||||
|
||||
```python
|
||||
from utilities.choices import ChoiceSet
|
||||
|
||||
class StatusChoices(ChoiceSet):
|
||||
key = 'MyModel.status'
|
||||
```
|
||||
|
||||
To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as:
|
||||
|
||||
```python
|
||||
FIELD_CHOICES = {
|
||||
'my_plugin.MyModel.status': (
|
||||
# Custom choices
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
# choices.py
|
||||
from utilities.choices import ChoiceSet
|
||||
|
||||
class StatusChoices(ChoiceSet):
|
||||
key = 'MyModel.status'
|
||||
|
||||
STATUS_FOO = 'foo'
|
||||
STATUS_BAR = 'bar'
|
||||
STATUS_BAZ = 'baz'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_FOO, 'Foo', 'red'),
|
||||
(STATUS_BAR, 'Bar', 'green'),
|
||||
(STATUS_BAZ, 'Baz', 'blue'),
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# models.py
|
||||
from django.db import models
|
||||
from .choices import StatusChoices
|
||||
|
||||
class MyModel(models.Model):
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=StatusChoices,
|
||||
default=StatusChoices.STATUS_FOO
|
||||
)
|
||||
```
|
||||
|
||||
50
docs/plugins/development/navigation.md
Normal file
50
docs/plugins/development/navigation.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Navigation
|
||||
|
||||
## Menu Items
|
||||
|
||||
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
|
||||
|
||||
!!! tip
|
||||
The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginMenuButton, PluginMenuItem
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(
|
||||
link='plugins:netbox_animal_sounds:random_animal',
|
||||
link_text='Random sound',
|
||||
buttons=(
|
||||
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
|
||||
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
|
||||
)
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
|
||||
## Menu Buttons
|
||||
|
||||
A `PluginMenuButton` has the following attributes:
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|--------------------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this button links |
|
||||
| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) |
|
||||
| `icon_class` | Yes | Button icon CSS class* |
|
||||
| `color` | - | One of the choices provided by `ButtonColorChoices` |
|
||||
| `permissions` | - | A list of permissions required to display this button |
|
||||
|
||||
*NetBox supports [Material Design Icons](https://materialdesignicons.com/).
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
@@ -1,46 +1,69 @@
|
||||
# REST API
|
||||
|
||||
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
|
||||
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer.
|
||||
|
||||
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
|
||||
Generally speaking, there aren't many NetBox-specific components to implementing REST API functionality in a plugin. NetBox employs the [Django REST Framework](https://www.django-rest-framework.org/) (DRF) for its REST API, and plugin authors will find that they can largely replicate the same patterns found in NetBox's implementation. Some brief examples are included here for reference.
|
||||
|
||||
## Serializers
|
||||
|
||||
Serializers are responsible for converting Python objects to JSON data suitable for conveying to consumers, and vice versa. NetBox provides the `NetBoxModelSerializer` class for use by plugins to handle the assignment of tags and custom field data. (These features can also be included ad hoc via the `CustomFieldModelSerializer` and `TaggableModelSerializer` classes.)
|
||||
|
||||
### Example
|
||||
|
||||
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.
|
||||
|
||||
```python
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from netbox_animal_sounds.models import Animal
|
||||
# api/serializers.py
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from my_plugin.models import MyModel
|
||||
|
||||
class AnimalSerializer(ModelSerializer):
|
||||
class MyModelSerializer(NetBoxModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Animal
|
||||
fields = ('id', 'name', 'sound')
|
||||
model = MyModel
|
||||
fields = ('id', 'foo', 'bar')
|
||||
```
|
||||
|
||||
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
|
||||
## Viewsets
|
||||
|
||||
Just as in the user interface, a REST API view handles the business logic of displaying and interacting with NetBox objects. NetBox provides the `NetBoxModelViewSet` class, which extends DRF's built-in `ModelViewSet` to handle bulk operations and object validation.
|
||||
|
||||
Unlike the user interface, typically only a single view set is required per model: This view set handles all request types (`GET`, `POST`, `DELETE`, etc.).
|
||||
|
||||
### Example
|
||||
|
||||
To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/views.py`, and define the `queryset` and `serializer_class` attributes.
|
||||
|
||||
```python
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from netbox_animal_sounds.models import Animal
|
||||
from .serializers import AnimalSerializer
|
||||
# api/views.py
|
||||
from netbox.api.viewsets import ModelViewSet
|
||||
from my_plugin.models import MyModel
|
||||
from .serializers import MyModelSerializer
|
||||
|
||||
class AnimalViewSet(ModelViewSet):
|
||||
queryset = Animal.objects.all()
|
||||
serializer_class = AnimalSerializer
|
||||
class MyModelViewSet(ModelViewSet):
|
||||
queryset = MyModel.objects.all()
|
||||
serializer_class = MyModelSerializer
|
||||
```
|
||||
|
||||
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
|
||||
## Routers
|
||||
|
||||
Routers map URLs to REST API views (endpoints). NetBox does not provide any custom components for this; the [`DefaultRouter`](https://www.django-rest-framework.org/api-guide/routers/#defaultrouter) class provided by DRF should suffice for most use cases.
|
||||
|
||||
Routers should be exposed in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
# api/urls.py
|
||||
from rest_framework import routers
|
||||
from .views import AnimalViewSet
|
||||
from .views import MyModelViewSet
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register('animals', AnimalViewSet)
|
||||
router.register('my-model', MyModelViewSet)
|
||||
urlpatterns = router.urls
|
||||
```
|
||||
|
||||
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
|
||||
|
||||

|
||||
This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`.
|
||||
|
||||
!!! warning
|
||||
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.
|
||||
The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address.
|
||||
|
||||
@@ -12,9 +12,9 @@ To provide additional functionality beyond what is supported by the stock `Table
|
||||
|
||||
It also includes several default columns:
|
||||
|
||||
* `pk` - A checkbox for selecting the object associated with each table row
|
||||
* `id` - The object's numeric database ID, as a hyperlink to the object's view
|
||||
* `actions` - A dropdown menu presenting object-specific actions available to the user.
|
||||
* `pk` - A checkbox for selecting the object associated with each table row (where applicable)
|
||||
* `id` - The object's numeric database ID, as a hyperlink to the object's view (hidden by default)
|
||||
* `actions` - A dropdown menu presenting object-specific actions available to the user
|
||||
|
||||
### Example
|
||||
|
||||
@@ -38,69 +38,51 @@ class MyModelTable(NetBoxTable):
|
||||
|
||||
### Table Configuration
|
||||
|
||||
The NetBoxTable class supports dynamic configuration to support pagination and to effect user preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:
|
||||
The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:
|
||||
|
||||
```python
|
||||
table = MyModelTable(data=MyModel.objects.all())
|
||||
table.configure(request)
|
||||
```
|
||||
|
||||
If using a generic view provided by NetBox, table configuration is handled automatically.
|
||||
This will automatically apply any user-specific preferences for the table. (If using a generic view provided by NetBox, table configuration is handled automatically.)
|
||||
|
||||
## Columns
|
||||
|
||||
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
||||
|
||||
::: netbox.tables.BooleanColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ChoiceFieldColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColorColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColoredLabelColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypeColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypesColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.MarkdownColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TagColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
rendering:
|
||||
show_source: false
|
||||
selection:
|
||||
members: false
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
# Templates
|
||||
|
||||
## Base Templates
|
||||
Templates are used to render HTML content generated from a set of context data. NetBox provides a set of built-in templates suitable for use in plugin views. Plugin authors can extend these templates to minimize the work needed to create custom templates while ensuring that the content they produce matches NetBox's layout and style. These templates are all written in the [Django Template Language (DTL)](https://docs.djangoproject.com/en/stable/ref/templates/language/).
|
||||
|
||||
## Standard Blocks
|
||||
|
||||
The following template blocks are available on all templates.
|
||||
|
||||
| Name | Required | Description |
|
||||
|--------------|----------|---------------------------------------------------------------------|
|
||||
| `title` | Yes | Page title |
|
||||
| `content` | Yes | Page content |
|
||||
| `head` | - | Content to include in the HTML `<head>` element |
|
||||
| `javascript` | - | Javascript content included at the end of the HTML `<body>` element |
|
||||
| Name | Required | Description |
|
||||
|----------------|----------|---------------------------------------------------------------------|
|
||||
| `title` | Yes | Page title |
|
||||
| `content` | Yes | Page content |
|
||||
| `head` | - | Content to include in the HTML `<head>` element |
|
||||
| `footer` | - | Page footer content |
|
||||
| `footer_links` | - | Links section of the page footer |
|
||||
| `javascript` | - | Javascript content included at the end of the HTML `<body>` element |
|
||||
|
||||
!!! note
|
||||
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
|
||||
|
||||
## Base Templates
|
||||
|
||||
### layout.html
|
||||
|
||||
Path: `base/layout.html`
|
||||
@@ -44,7 +50,7 @@ An example of a plugin template which extends `layout.html` is included below.
|
||||
{% endblock content %}
|
||||
```
|
||||
|
||||
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
|
||||
The first line of the template instructs Django to extend the NetBox base template, and the `block` sections inject our custom content within its `header` and `content` blocks.
|
||||
|
||||
!!! note
|
||||
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important distinctions of which authors should be aware. Be sure to familiarize yourself with Django's template language before attempting to create new templates.
|
||||
@@ -126,14 +132,14 @@ This template is used by the `ObjectListView` generic view to display a filterab
|
||||
|
||||
#### Context
|
||||
|
||||
| Name | Required | Description |
|
||||
|------------------|----------|-----------------------------------------------------------------------|
|
||||
| `model` | Yes | The object class |
|
||||
| `table` | Yes | The table class used for rendering the list of objects |
|
||||
| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user |
|
||||
| `action_buttons` | Yes | A list of buttons to display (options are `add`, `import`, `export`) |
|
||||
| `filter_form` | - | The bound filterset form for filtering the objects list |
|
||||
| `return_url` | - | The return URL to pass when submitting a bulk operation form |
|
||||
| Name | Required | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------------------|
|
||||
| `model` | Yes | The object class |
|
||||
| `table` | Yes | The table class used for rendering the list of objects |
|
||||
| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user |
|
||||
| `actions` | Yes | A list of buttons to display (`add`, `import`, `export`, `bulk_edit`, and/or `bulk_delete`) |
|
||||
| `filter_form` | - | The bound filterset form for filtering the objects list |
|
||||
| `return_url` | - | The return URL to pass when submitting a bulk operation form |
|
||||
|
||||
### bulk_import.html
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Views
|
||||
|
||||
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
|
||||
## Writing Views
|
||||
|
||||
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
|
||||
|
||||
As an example, let's write a view which displays a random animal and the sound it makes. We'll use Django's generic `View` class to minimize the amount of boilerplate code needed.
|
||||
|
||||
```python
|
||||
from django.shortcuts import render
|
||||
@@ -18,39 +22,11 @@ class RandomAnimalView(View):
|
||||
})
|
||||
```
|
||||
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
|
||||
This view retrieves a random Animal instance from the database and passes it as a context variable when rendering a template named `animal.html`. HTTP `GET` requests are handled by the view's `get()` method, and `POST` requests are handled by its `post()` method.
|
||||
|
||||
## View Classes
|
||||
Our example above is extremely simple, but views can do just about anything. They are generally where the core of your plugin's functionality will reside. Views also are not limited to returning HTML content: A view could return a CSV file or image, for instance. For more information on views, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/class-based-views/).
|
||||
|
||||
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
||||
|
||||
| View Class | Description |
|
||||
|------------|-------------|
|
||||
| `ObjectView` | View a single object |
|
||||
| `ObjectEditView` | Create or edit a single object |
|
||||
| `ObjectDeleteView` | Delete a single object |
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
# views.py
|
||||
from netbox.views.generic import ObjectEditView
|
||||
from .models import Thing
|
||||
|
||||
class ThingEditView(ObjectEditView):
|
||||
queryset = Thing.objects.all()
|
||||
template_name = 'myplugin/thing.html'
|
||||
...
|
||||
```
|
||||
|
||||
## URL Registration
|
||||
### URL Registration
|
||||
|
||||
To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
|
||||
|
||||
@@ -71,6 +47,98 @@ A URL pattern has three components:
|
||||
|
||||
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
|
||||
|
||||
### View Classes
|
||||
|
||||
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
||||
|
||||
| View Class | Description |
|
||||
|--------------------|--------------------------------|
|
||||
| `ObjectView` | View a single object |
|
||||
| `ObjectEditView` | Create or edit a single object |
|
||||
| `ObjectDeleteView` | Delete a single object |
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```python
|
||||
# views.py
|
||||
from netbox.views.generic import ObjectEditView
|
||||
from .models import Thing
|
||||
|
||||
class ThingEditView(ObjectEditView):
|
||||
queryset = Thing.objects.all()
|
||||
template_name = 'myplugin/thing.html'
|
||||
...
|
||||
```
|
||||
## Object Views
|
||||
|
||||
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
|
||||
|
||||
::: netbox.views.generic.base.BaseObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
- get_template_name
|
||||
|
||||
::: netbox.views.generic.ObjectEditView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
- alter_object
|
||||
|
||||
::: netbox.views.generic.ObjectDeleteView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
|
||||
## Multi-Object Views
|
||||
|
||||
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
|
||||
|
||||
::: netbox.views.generic.base.BaseMultiObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectListView
|
||||
selection:
|
||||
members:
|
||||
- get_table
|
||||
- export_table
|
||||
- export_template
|
||||
|
||||
::: netbox.views.generic.BulkImportView
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkEditView
|
||||
selection:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
## Feature Views
|
||||
|
||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||
|
||||
::: netbox.views.generic.ObjectChangeLogView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
::: netbox.views.generic.ObjectJournalView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
## Extending Core Views
|
||||
|
||||
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
|
||||
@@ -107,126 +175,3 @@ class SiteAnimalCount(PluginTemplateExtension):
|
||||
|
||||
template_extensions = [SiteAnimalCount]
|
||||
```
|
||||
|
||||
## Navigation Menu Items
|
||||
|
||||
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
|
||||
|
||||
```python
|
||||
from extras.plugins import PluginMenuButton, PluginMenuItem
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(
|
||||
link='plugins:netbox_animal_sounds:random_animal',
|
||||
link_text='Random sound',
|
||||
buttons=(
|
||||
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
|
||||
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
|
||||
)
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
* `link` - The name of the URL path to which this menu item links
|
||||
* `link_text` - The text presented to the user
|
||||
* `permissions` - A list of permissions required to display this link (optional)
|
||||
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
|
||||
|
||||
A `PluginMenuButton` has the following attributes:
|
||||
|
||||
* `link` - The name of the URL path to which this button links
|
||||
* `title` - The tooltip text (displayed when the mouse hovers over the button)
|
||||
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
|
||||
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
|
||||
* `permissions` - A list of permissions required to display this button (optional)
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
|
||||
## Object Views
|
||||
|
||||
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
|
||||
|
||||
::: netbox.views.generic.base.BaseObjectView
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.ObjectView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
- get_template_name
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.ObjectEditView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
- alter_object
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.ObjectDeleteView
|
||||
selection:
|
||||
members:
|
||||
- get_object
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
## Multi-Object Views
|
||||
|
||||
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
|
||||
|
||||
::: netbox.views.generic.base.BaseMultiObjectView
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.ObjectListView
|
||||
selection:
|
||||
members:
|
||||
- get_table
|
||||
- export_table
|
||||
- export_template
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.BulkImportView
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.BulkEditView
|
||||
selection:
|
||||
members: false
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
## Feature Views
|
||||
|
||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||
|
||||
::: netbox.views.generic.ObjectChangeLogView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
::: netbox.views.generic.ObjectJournalView
|
||||
selection:
|
||||
members:
|
||||
- get_form
|
||||
rendering:
|
||||
show_source: false
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## v3.1.9 (FUTURE)
|
||||
## v3.1.10 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.1.9 (2022-03-07)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8594](https://github.com/netbox-community/netbox/issues/8594) - Enable filtering by exact description match for all applicable models
|
||||
* [#8629](https://github.com/netbox-community/netbox/issues/8629) - Add description to tag table search function
|
||||
* [#8664](https://github.com/netbox-community/netbox/issues/8664) - Show assigned ASNs/sites under list views
|
||||
* [#8736](https://github.com/netbox-community/netbox/issues/8736) - Add PC and UPC fiber end faces for LC/SC/LSH port types
|
||||
* [#8758](https://github.com/netbox-community/netbox/issues/8758) - Allow empty string substitution when renaming objects in bulk
|
||||
* [#8762](https://github.com/netbox-community/netbox/issues/8762) - Link to rack elevations list from site view
|
||||
* [#8766](https://github.com/netbox-community/netbox/issues/8766) - Add SCTP to service protocols list
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces
|
||||
* [#8633](https://github.com/netbox-community/netbox/issues/8633) - Prevent navigation sidebar pin from disappearing at certain breakpoints
|
||||
* [#8674](https://github.com/netbox-community/netbox/issues/8674) - Fix rendering of tabbed content in documentation
|
||||
* [#8710](https://github.com/netbox-community/netbox/issues/8710) - Fix dynamic scope selection form fields when creating a VLAN group
|
||||
* [#8713](https://github.com/netbox-community/netbox/issues/8713) - Restore missing "add" button on services list view
|
||||
* [#8715](https://github.com/netbox-community/netbox/issues/8715) - Avoid returning multiple objects when restricting querysets using multiple tags in permissions
|
||||
* [#8717](https://github.com/netbox-community/netbox/issues/8717) - Fix redirection after bulk edit/delete of prefixes from aggregate view
|
||||
* [#8724](https://github.com/netbox-community/netbox/issues/8724) - Fix exception during device import with invalid device type
|
||||
* [#8807](https://github.com/netbox-community/netbox/issues/8807) - Correct REST API URL for FHRP group assignments
|
||||
* [#8808](https://github.com/netbox-community/netbox/issues/8808) - Fix members count under FHRP group list
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ NetBox's plugins framework has been extended considerably in this release. Addit
|
||||
* The `NetBoxTable` base class for rendering object tables with `django-tables2`, as well as various custom column classes
|
||||
* Function-specific templates (for generic views)
|
||||
* Various custom template tags and filters
|
||||
* `NetBoxModelViewSet` and several base serializer classes now provide enhanced REST API functionality
|
||||
* Plugins can now extend NetBox's GraphQL API with their own schema
|
||||
|
||||
No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their existing code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized.
|
||||
@@ -144,6 +145,24 @@ Where it is desired to limit the range of available VLANs within a group, users
|
||||
* [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports
|
||||
* [#8649](https://github.com/netbox-community/netbox/issues/8649) - Enable customization of configuration module using `NETBOX_CONFIGURATION` environment variable
|
||||
|
||||
### Bug Fixes (From Beta1)
|
||||
|
||||
* [#8655](https://github.com/netbox-community/netbox/issues/8655) - Fix AttributeError when viewing cabled interfaces
|
||||
* [#8656](https://github.com/netbox-community/netbox/issues/8656) - Fix migration error when upgrading from a v2.11 database
|
||||
* [#8659](https://github.com/netbox-community/netbox/issues/8659) - Fix display of multi-object custom fields after deleting related object
|
||||
* [#8661](https://github.com/netbox-community/netbox/issues/8661) - Fix ValueError exception when trying to connect a cable
|
||||
* [#8670](https://github.com/netbox-community/netbox/issues/8670) - Fix filtering device components by installed module
|
||||
* [#8671](https://github.com/netbox-community/netbox/issues/8671) - Fix AttributeError when viewing console/power/interface connection lists
|
||||
* [#8682](https://github.com/netbox-community/netbox/issues/8682) - Limit available VLANs by group min/max VIDs
|
||||
* [#8683](https://github.com/netbox-community/netbox/issues/8683) - Fix `ZoneInfoNotFoundError` exception under Python 3.9+
|
||||
* [#8761](https://github.com/netbox-community/netbox/issues/8761) - Correct view name resolution under journal entry views
|
||||
* [#8763](https://github.com/netbox-community/netbox/issues/8763) - Fix inventory item component assignment
|
||||
* [#8764](https://github.com/netbox-community/netbox/issues/8764) - Correct view name resolution for dynamic form fields
|
||||
* [#8791](https://github.com/netbox-community/netbox/issues/8791) - Fix display of form validation failures during device component creation
|
||||
* [#8792](https://github.com/netbox-community/netbox/issues/8792) - Fix creation of circuit terminations via UI
|
||||
* [#8810](https://github.com/netbox-community/netbox/issues/8810) - Enable filtering modules by type
|
||||
* [#8815](https://github.com/netbox-community/netbox/issues/8815) - Fix display of custom object fields in table columns
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later
|
||||
@@ -152,6 +171,7 @@ Where it is desired to limit the range of available VLANs within a group, users
|
||||
* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs
|
||||
* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys
|
||||
* [#8509](https://github.com/netbox-community/netbox/issues/8509) - `CSRF_TRUSTED_ORIGINS` is now a discrete configuration parameter (rather than being populated from `ALLOWED_HOSTS`)
|
||||
* [#8684](https://github.com/netbox-community/netbox/issues/8684) - Change custom link template context variable `obj` to `object` (backward-compatible)
|
||||
|
||||
### REST API Changes
|
||||
|
||||
|
||||
15
mkdocs.yml
15
mkdocs.yml
@@ -8,11 +8,13 @@ theme:
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
palette:
|
||||
- scheme: default
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
toggle:
|
||||
icon: material/lightbulb-outline
|
||||
name: Switch to Dark Mode
|
||||
- scheme: slate
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
@@ -32,6 +34,7 @@ plugins:
|
||||
show_root_heading: true
|
||||
show_root_full_path: false
|
||||
show_root_toc_entry: false
|
||||
show_source: false
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
@@ -50,7 +53,8 @@ markdown_extensions:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
nav:
|
||||
- Introduction: 'index.md'
|
||||
- Installation:
|
||||
@@ -104,12 +108,13 @@ nav:
|
||||
- Getting Started: 'plugins/development/index.md'
|
||||
- Models: 'plugins/development/models.md'
|
||||
- Views: 'plugins/development/views.md'
|
||||
- Navigation: 'plugins/development/navigation.md'
|
||||
- Templates: 'plugins/development/templates.md'
|
||||
- Tables: 'plugins/development/tables.md'
|
||||
- Forms: 'plugins/development/forms.md'
|
||||
- Filter Sets: 'plugins/development/filtersets.md'
|
||||
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||
- REST API: 'plugins/development/rest-api.md'
|
||||
- GraphQL API: 'plugins/development/graphql.md'
|
||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||
- Administration:
|
||||
- Authentication: 'administration/authentication.md'
|
||||
|
||||
@@ -5,7 +5,7 @@ from circuits.models import *
|
||||
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
|
||||
from dcim.api.serializers import LinkTerminationSerializer
|
||||
from netbox.api import ChoiceField
|
||||
from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
@@ -14,7 +14,7 @@ from .nested_serializers import *
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderSerializer(PrimaryModelSerializer):
|
||||
class ProviderSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProviderSerializer(PrimaryModelSerializer):
|
||||
# Provider networks
|
||||
#
|
||||
|
||||
class ProviderNetworkSerializer(PrimaryModelSerializer):
|
||||
class ProviderNetworkSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
|
||||
provider = NestedProviderSerializer()
|
||||
|
||||
@@ -46,7 +46,7 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitTypeSerializer(PrimaryModelSerializer):
|
||||
class CircuitTypeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -70,7 +70,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CircuitSerializer(PrimaryModelSerializer):
|
||||
class CircuitSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
provider = NestedProviderSerializer()
|
||||
status = ChoiceField(choices=CircuitStatusChoices, required=False)
|
||||
|
||||
@@ -3,8 +3,7 @@ from rest_framework.routers import APIRootView
|
||||
from circuits import filtersets
|
||||
from circuits.models import *
|
||||
from dcim.api.views import PassThroughPortMixin
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
@@ -21,7 +20,7 @@ class CircuitsRootView(APIRootView):
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
class ProviderViewSet(NetBoxModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=count_related(Circuit, 'provider')
|
||||
)
|
||||
@@ -33,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
class CircuitTypeViewSet(CustomFieldModelViewSet):
|
||||
class CircuitTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = CircuitType.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
@@ -45,7 +44,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
class CircuitViewSet(NetBoxModelViewSet):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'type', 'tenant', 'provider', 'termination_a', 'termination_z'
|
||||
).prefetch_related('tags')
|
||||
@@ -57,7 +56,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = CircuitTermination.objects.prefetch_related(
|
||||
'circuit', 'site', 'provider_network', 'cable'
|
||||
)
|
||||
@@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
# Provider networks
|
||||
#
|
||||
|
||||
class ProviderNetworkViewSet(CustomFieldModelViewSet):
|
||||
class ProviderNetworkViewSet(NetBoxModelViewSet):
|
||||
queryset = ProviderNetwork.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.ProviderNetworkSerializer
|
||||
filterset_class = filtersets.ProviderNetworkFilterSet
|
||||
|
||||
@@ -6,7 +6,7 @@ from utilities.choices import ChoiceSet
|
||||
#
|
||||
|
||||
class CircuitStatusChoices(ChoiceSet):
|
||||
key = 'circuits.Circuit.status'
|
||||
key = 'Circuit.status'
|
||||
|
||||
STATUS_DEPROVISIONING = 'deprovisioning'
|
||||
STATUS_ACTIVE = 'active'
|
||||
|
||||
@@ -95,7 +95,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ProviderNetwork
|
||||
fields = ['id', 'name', 'service_id']
|
||||
fields = ['id', 'name', 'service_id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -112,7 +112,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
@@ -189,7 +189,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'install_date', 'commit_rate']
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -230,7 +230,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -125,6 +125,19 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'circuits': '$circuit'
|
||||
}
|
||||
)
|
||||
circuit = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
query_params={
|
||||
'provider_id': '$provider',
|
||||
},
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -155,8 +168,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
|
||||
'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
@@ -164,12 +177,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
'pp_info': "Patch panel ID and port number(s)"
|
||||
}
|
||||
widgets = {
|
||||
'term_side': forms.HiddenInput(),
|
||||
'term_side': StaticSelect(),
|
||||
'port_speed': SelectSpeedWidget(),
|
||||
'upstream_speed': SelectSpeedWidget(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)
|
||||
|
||||
3
netbox/circuits/tables/__init__.py
Normal file
3
netbox/circuits/tables/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .circuits import *
|
||||
from .columns import *
|
||||
from .providers import *
|
||||
@@ -1,15 +1,13 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from .models import *
|
||||
from .columns import CommitRateColumn
|
||||
|
||||
__all__ = (
|
||||
'CircuitTable',
|
||||
'CircuitTypeTable',
|
||||
'ProviderTable',
|
||||
'ProviderNetworkTable',
|
||||
)
|
||||
|
||||
|
||||
@@ -22,81 +20,6 @@ CIRCUITTERMINATION_LINK = """
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Table columns
|
||||
#
|
||||
|
||||
class CommitRateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Humanize the commit rate in the column view
|
||||
"""
|
||||
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{{ record.commit_rate|humanize_speed }}
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(template_code=self.template_code, *args, **kwargs)
|
||||
|
||||
def value(self, value):
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
circuit_count = tables.Column(
|
||||
accessor=Accessor('count_circuits'),
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
|
||||
#
|
||||
# Provider networks
|
||||
#
|
||||
|
||||
class ProviderNetworkTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
provider = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:providernetwork_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Circuit types
|
||||
#
|
||||
|
||||
class CircuitTypeTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
@@ -116,10 +39,6 @@ class CircuitTypeTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitTable(NetBoxTable):
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
21
netbox/circuits/tables/columns.py
Normal file
21
netbox/circuits/tables/columns.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
__all__ = (
|
||||
'CommitRateColumn',
|
||||
)
|
||||
|
||||
|
||||
class CommitRateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Humanize the commit rate in the column view
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{{ record.commit_rate|humanize_speed }}
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(template_code=self.template_code, *args, **kwargs)
|
||||
|
||||
def value(self, value):
|
||||
return str(value) if value else None
|
||||
52
netbox/circuits/tables/providers.py
Normal file
52
netbox/circuits/tables/providers.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
__all__ = (
|
||||
'ProviderTable',
|
||||
'ProviderNetworkTable',
|
||||
)
|
||||
|
||||
|
||||
class ProviderTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
circuit_count = tables.Column(
|
||||
accessor=Accessor('count_circuits'),
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
|
||||
class ProviderNetworkTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
provider = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:providernetwork_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
|
||||
@@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
CircuitType.objects.bulk_create((
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1', description='foobar1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
|
||||
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
|
||||
))
|
||||
|
||||
@@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'slug': ['circuit-type-1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Circuit.objects.all()
|
||||
@@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
@@ -241,6 +245,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
|
||||
@@ -349,6 +357,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'xconnect_id': ['ABC', 'DEF']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_circuit_id(self):
|
||||
circuits = Circuit.objects.all()[:2]
|
||||
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
|
||||
@@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
provider_networks = (
|
||||
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
|
||||
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
|
||||
ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
|
||||
ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
|
||||
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
|
||||
)
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
@@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'name': ['Provider Network 1', 'Provider Network 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_provider(self):
|
||||
providers = Provider.objects.all()[:2]
|
||||
params = {'provider_id': [providers[0].pk, providers[1].pk]}
|
||||
|
||||
@@ -218,6 +218,7 @@ class CircuitTerminationTestCase(
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
cls.form_data = {
|
||||
'circuit': circuits[2].pk,
|
||||
'term_side': 'A',
|
||||
'site': sites[2].pk,
|
||||
'description': 'New description',
|
||||
|
||||
@@ -57,7 +57,7 @@ urlpatterns = [
|
||||
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
|
||||
|
||||
# Circuit terminations
|
||||
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
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}),
|
||||
|
||||
@@ -318,14 +318,6 @@ class CircuitTerminationEditView(generic.ObjectEditView):
|
||||
model_form = forms.CircuitTerminationForm
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
if 'circuit' in url_kwargs:
|
||||
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
|
||||
@@ -12,7 +12,7 @@ from ipam.api.nested_serializers import (
|
||||
from ipam.models import ASN, VLAN
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import (
|
||||
NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
)
|
||||
from netbox.config import ConfigItem
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
@@ -109,7 +109,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SiteSerializer(PrimaryModelSerializer):
|
||||
class SiteSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
|
||||
status = ChoiceField(choices=SiteStatusChoices, required=False)
|
||||
region = NestedRegionSerializer(required=False, allow_null=True)
|
||||
@@ -161,7 +161,7 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class RackRoleSerializer(PrimaryModelSerializer):
|
||||
class RackRoleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -173,7 +173,7 @@ class RackRoleSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class RackSerializer(PrimaryModelSerializer):
|
||||
class RackSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
|
||||
site = NestedSiteSerializer()
|
||||
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||
@@ -212,7 +212,7 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
return obj['name']
|
||||
|
||||
|
||||
class RackReservationSerializer(PrimaryModelSerializer):
|
||||
class RackReservationSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
@@ -266,7 +266,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
# Device/module types
|
||||
#
|
||||
|
||||
class ManufacturerSerializer(PrimaryModelSerializer):
|
||||
class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
@@ -280,7 +280,7 @@ class ManufacturerSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceTypeSerializer(PrimaryModelSerializer):
|
||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
|
||||
@@ -296,7 +296,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ModuleTypeSerializer(PrimaryModelSerializer):
|
||||
class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
# module_count = serializers.IntegerField(read_only=True)
|
||||
@@ -487,7 +487,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceRoleSerializer(PrimaryModelSerializer):
|
||||
class DeviceRoleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
@@ -500,7 +500,7 @@ class DeviceRoleSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class PlatformSerializer(PrimaryModelSerializer):
|
||||
class PlatformSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -514,7 +514,7 @@ class PlatformSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceSerializer(PrimaryModelSerializer):
|
||||
class DeviceSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
@@ -556,7 +556,7 @@ class DeviceSerializer(PrimaryModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class ModuleSerializer(PrimaryModelSerializer):
|
||||
class ModuleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module_bay = NestedModuleBaySerializer()
|
||||
@@ -594,7 +594,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -622,7 +622,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -650,7 +650,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -685,7 +685,7 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
||||
]
|
||||
|
||||
|
||||
class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -709,7 +709,7 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -768,7 +768,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
|
||||
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -798,7 +798,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'display', 'name', 'label']
|
||||
|
||||
|
||||
class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
|
||||
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
module = ComponentNestedModuleSerializer(
|
||||
@@ -818,7 +818,7 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ModuleBaySerializer(PrimaryModelSerializer):
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
|
||||
@@ -831,7 +831,7 @@ class ModuleBaySerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceBaySerializer(PrimaryModelSerializer):
|
||||
class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
@@ -844,7 +844,7 @@ class DeviceBaySerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class InventoryItemSerializer(PrimaryModelSerializer):
|
||||
class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
@@ -879,7 +879,7 @@ class InventoryItemSerializer(PrimaryModelSerializer):
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
class InventoryItemRoleSerializer(PrimaryModelSerializer):
|
||||
class InventoryItemRoleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -895,7 +895,7 @@ class InventoryItemRoleSerializer(PrimaryModelSerializer):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableSerializer(PrimaryModelSerializer):
|
||||
class CableSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
@@ -1001,7 +1001,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(PrimaryModelSerializer):
|
||||
class VirtualChassisSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
@@ -1018,7 +1018,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelSerializer(PrimaryModelSerializer):
|
||||
class PowerPanelSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
site = NestedSiteSerializer()
|
||||
location = NestedLocationSerializer(
|
||||
@@ -1036,7 +1036,7 @@ class PowerPanelSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
|
||||
@@ -14,12 +14,12 @@ from rest_framework.viewsets import ViewSet
|
||||
from circuits.models import Circuit
|
||||
from dcim import filtersets
|
||||
from dcim.models import *
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from extras.api.views import ConfigContextQuerySetMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.config import get_config
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
@@ -103,7 +103,7 @@ class PassThroughPortMixin(object):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(CustomFieldModelViewSet):
|
||||
class RegionViewSet(NetBoxModelViewSet):
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
@@ -119,7 +119,7 @@ class RegionViewSet(CustomFieldModelViewSet):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupViewSet(CustomFieldModelViewSet):
|
||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
SiteGroup.objects.all(),
|
||||
Site,
|
||||
@@ -135,7 +135,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteViewSet(CustomFieldModelViewSet):
|
||||
class SiteViewSet(NetBoxModelViewSet):
|
||||
queryset = Site.objects.prefetch_related(
|
||||
'region', 'tenant', 'asns', 'tags'
|
||||
).annotate(
|
||||
@@ -154,7 +154,7 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationViewSet(CustomFieldModelViewSet):
|
||||
class LocationViewSet(NetBoxModelViewSet):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
@@ -176,7 +176,7 @@ class LocationViewSet(CustomFieldModelViewSet):
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleViewSet(CustomFieldModelViewSet):
|
||||
class RackRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = RackRole.objects.prefetch_related('tags').annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
@@ -188,7 +188,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackViewSet(CustomFieldModelViewSet):
|
||||
class RackViewSet(NetBoxModelViewSet):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'location', 'role', 'tenant', 'tags'
|
||||
).annotate(
|
||||
@@ -250,7 +250,7 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationViewSet(ModelViewSet):
|
||||
class RackReservationViewSet(NetBoxModelViewSet):
|
||||
queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
|
||||
serializer_class = serializers.RackReservationSerializer
|
||||
filterset_class = filtersets.RackReservationFilterSet
|
||||
@@ -260,7 +260,7 @@ class RackReservationViewSet(ModelViewSet):
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerViewSet(CustomFieldModelViewSet):
|
||||
class ManufacturerViewSet(NetBoxModelViewSet):
|
||||
queryset = Manufacturer.objects.prefetch_related('tags').annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
@@ -274,7 +274,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
|
||||
# Device/module types
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
class DeviceTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
|
||||
device_count=count_related(Device, 'device_type')
|
||||
)
|
||||
@@ -283,7 +283,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
brief_prefetch_fields = ['manufacturer']
|
||||
|
||||
|
||||
class ModuleTypeViewSet(CustomFieldModelViewSet):
|
||||
class ModuleTypeViewSet(NetBoxModelViewSet):
|
||||
queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate(
|
||||
# module_count=count_related(Module, 'module_type')
|
||||
)
|
||||
@@ -296,61 +296,61 @@ class ModuleTypeViewSet(CustomFieldModelViewSet):
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ConsolePortTemplateViewSet(ModelViewSet):
|
||||
class ConsolePortTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||
filterset_class = filtersets.ConsolePortTemplateFilterSet
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateViewSet(ModelViewSet):
|
||||
class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||
filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
|
||||
|
||||
|
||||
class PowerPortTemplateViewSet(ModelViewSet):
|
||||
class PowerPortTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerPortTemplateSerializer
|
||||
filterset_class = filtersets.PowerPortTemplateFilterSet
|
||||
|
||||
|
||||
class PowerOutletTemplateViewSet(ModelViewSet):
|
||||
class PowerOutletTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||
filterset_class = filtersets.PowerOutletTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceTemplateViewSet(ModelViewSet):
|
||||
class InterfaceTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.InterfaceTemplateSerializer
|
||||
filterset_class = filtersets.InterfaceTemplateFilterSet
|
||||
|
||||
|
||||
class FrontPortTemplateViewSet(ModelViewSet):
|
||||
class FrontPortTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.FrontPortTemplateSerializer
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class RearPortTemplateViewSet(ModelViewSet):
|
||||
class RearPortTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.RearPortTemplateSerializer
|
||||
filterset_class = filtersets.RearPortTemplateFilterSet
|
||||
|
||||
|
||||
class ModuleBayTemplateViewSet(ModelViewSet):
|
||||
class ModuleBayTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ModuleBayTemplateSerializer
|
||||
filterset_class = filtersets.ModuleBayTemplateFilterSet
|
||||
|
||||
|
||||
class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
|
||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemTemplateViewSet(ModelViewSet):
|
||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||
@@ -360,7 +360,7 @@ class InventoryItemTemplateViewSet(ModelViewSet):
|
||||
# Device roles
|
||||
#
|
||||
|
||||
class DeviceRoleViewSet(CustomFieldModelViewSet):
|
||||
class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceRole.objects.prefetch_related('tags').annotate(
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
@@ -373,7 +373,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformViewSet(CustomFieldModelViewSet):
|
||||
class PlatformViewSet(NetBoxModelViewSet):
|
||||
queryset = Platform.objects.prefetch_related('tags').annotate(
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
@@ -386,7 +386,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
|
||||
# Devices/modules
|
||||
#
|
||||
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@@ -532,7 +532,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
return Response(response)
|
||||
|
||||
|
||||
class ModuleViewSet(CustomFieldModelViewSet):
|
||||
class ModuleViewSet(NetBoxModelViewSet):
|
||||
queryset = Module.objects.prefetch_related(
|
||||
'device', 'module_bay', 'module_type__manufacturer', 'tags',
|
||||
)
|
||||
@@ -544,7 +544,7 @@ class ModuleViewSet(CustomFieldModelViewSet):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
)
|
||||
@@ -553,7 +553,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
)
|
||||
@@ -562,7 +562,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
)
|
||||
@@ -571,7 +571,7 @@ class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related(
|
||||
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
)
|
||||
@@ -580,7 +580,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
|
||||
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
|
||||
@@ -590,7 +590,7 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related(
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
|
||||
)
|
||||
@@ -599,7 +599,7 @@ class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related(
|
||||
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
|
||||
)
|
||||
@@ -608,21 +608,21 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class ModuleBayViewSet(ModelViewSet):
|
||||
class ModuleBayViewSet(NetBoxModelViewSet):
|
||||
queryset = ModuleBay.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.ModuleBaySerializer
|
||||
filterset_class = filtersets.ModuleBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
class DeviceBayViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
filterset_class = filtersets.DeviceBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
@@ -633,7 +633,7 @@ class InventoryItemViewSet(ModelViewSet):
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
class InventoryItemRoleViewSet(CustomFieldModelViewSet):
|
||||
class InventoryItemRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
|
||||
inventoryitem_count=count_related(InventoryItem, 'role')
|
||||
)
|
||||
@@ -645,7 +645,7 @@ class InventoryItemRoleViewSet(CustomFieldModelViewSet):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableViewSet(ModelViewSet):
|
||||
class CableViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
'termination_a', 'termination_b'
|
||||
@@ -658,7 +658,7 @@ class CableViewSet(ModelViewSet):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
class VirtualChassisViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
@@ -671,7 +671,7 @@ class VirtualChassisViewSet(ModelViewSet):
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelViewSet(ModelViewSet):
|
||||
class PowerPanelViewSet(NetBoxModelViewSet):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'location'
|
||||
).annotate(
|
||||
@@ -685,7 +685,7 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
|
||||
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related(
|
||||
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from utilities.choices import ChoiceSet
|
||||
#
|
||||
|
||||
class SiteStatusChoices(ChoiceSet):
|
||||
key = 'dcim.Site.status'
|
||||
key = 'Site.status'
|
||||
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGING = 'staging'
|
||||
@@ -60,7 +60,7 @@ class RackWidthChoices(ChoiceSet):
|
||||
|
||||
|
||||
class RackStatusChoices(ChoiceSet):
|
||||
key = 'dcim.Rack.status'
|
||||
key = 'Rack.status'
|
||||
|
||||
STATUS_RESERVED = 'reserved'
|
||||
STATUS_AVAILABLE = 'available'
|
||||
@@ -130,7 +130,7 @@ class DeviceFaceChoices(ChoiceSet):
|
||||
|
||||
|
||||
class DeviceStatusChoices(ChoiceSet):
|
||||
key = 'dcim.Device.status'
|
||||
key = 'Device.status'
|
||||
|
||||
STATUS_OFFLINE = 'offline'
|
||||
STATUS_ACTIVE = 'active'
|
||||
@@ -1003,13 +1003,19 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
TYPE_ST = 'st'
|
||||
TYPE_SC = 'sc'
|
||||
TYPE_SC_PC = 'sc-pc'
|
||||
TYPE_SC_UPC = 'sc-upc'
|
||||
TYPE_SC_APC = 'sc-apc'
|
||||
TYPE_FC = 'fc'
|
||||
TYPE_LC = 'lc'
|
||||
TYPE_LC_PC = 'lc-pc'
|
||||
TYPE_LC_UPC = 'lc-upc'
|
||||
TYPE_LC_APC = 'lc-apc'
|
||||
TYPE_MTRJ = 'mtrj'
|
||||
TYPE_MPO = 'mpo'
|
||||
TYPE_LSH = 'lsh'
|
||||
TYPE_LSH_PC = 'lsh-pc'
|
||||
TYPE_LSH_UPC = 'lsh-upc'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
@@ -1049,12 +1055,18 @@ class PortTypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_FC, 'FC'),
|
||||
(TYPE_LC, 'LC'),
|
||||
(TYPE_LC_PC, 'LC/PC'),
|
||||
(TYPE_LC_UPC, 'LC/UPC'),
|
||||
(TYPE_LC_APC, 'LC/APC'),
|
||||
(TYPE_LSH, 'LSH'),
|
||||
(TYPE_LSH_PC, 'LSH/PC'),
|
||||
(TYPE_LSH_UPC, 'LSH/UPC'),
|
||||
(TYPE_LSH_APC, 'LSH/APC'),
|
||||
(TYPE_MPO, 'MPO'),
|
||||
(TYPE_MTRJ, 'MTRJ'),
|
||||
(TYPE_SC, 'SC'),
|
||||
(TYPE_SC_PC, 'SC/PC'),
|
||||
(TYPE_SC_UPC, 'SC/UPC'),
|
||||
(TYPE_SC_APC, 'SC/APC'),
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_CS, 'CS'),
|
||||
@@ -1175,7 +1187,7 @@ class CableLengthUnitChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class PowerFeedStatusChoices(ChoiceSet):
|
||||
key = 'dcim.PowerFeed.status'
|
||||
key = 'PowerFeed.status'
|
||||
|
||||
STATUS_OFFLINE = 'offline'
|
||||
STATUS_ACTIVE = 'active'
|
||||
|
||||
@@ -149,7 +149,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = (
|
||||
'id', 'name', 'slug', 'facility', 'latitude', 'longitude',
|
||||
'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description'
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -239,7 +239,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
|
||||
class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
@@ -385,7 +385,7 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'created']
|
||||
fields = ['id', 'created', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -724,7 +724,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role']
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
|
||||
|
||||
|
||||
class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
@@ -972,6 +972,17 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
module_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_type',
|
||||
queryset=ModuleType.objects.all(),
|
||||
label='Module type (ID)',
|
||||
)
|
||||
module_type = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_type__model',
|
||||
queryset=ModuleType.objects.all(),
|
||||
to_field_name='model',
|
||||
label='Module type (model)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
@@ -1076,6 +1087,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
|
||||
"""
|
||||
Extends DeviceComponentFilterSet to add a module_id filter for components
|
||||
which can be associated with a particular module within a device.
|
||||
"""
|
||||
module_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Module.objects.all(),
|
||||
label='Module (ID)',
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(django_filters.FilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
@@ -1096,7 +1118,12 @@ class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
|
||||
|
||||
|
||||
class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class ConsolePortFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
@@ -1107,7 +1134,12 @@ class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, Cable
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class ConsoleServerPortFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
@@ -1118,7 +1150,12 @@ class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet,
|
||||
fields = ['id', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class PowerPortFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerPortTypeChoices,
|
||||
null_value=None
|
||||
@@ -1129,7 +1166,12 @@ class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
|
||||
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
|
||||
|
||||
|
||||
class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class PowerOutletFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletTypeChoices,
|
||||
null_value=None
|
||||
@@ -1144,7 +1186,12 @@ class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, Cable
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description']
|
||||
|
||||
|
||||
class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
class InterfaceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1271,7 +1318,11 @@ class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
|
||||
}.get(value, queryset.none())
|
||||
|
||||
|
||||
class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
class FrontPortFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
@@ -1282,7 +1333,11 @@ class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
|
||||
fields = ['id', 'name', 'label', 'type', 'color', 'description']
|
||||
|
||||
|
||||
class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
class RearPortFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
ModularDeviceComponentFilterSet,
|
||||
CableTerminationFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
|
||||
@@ -647,6 +647,19 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
||||
'rf_channel_width', 'tx_power',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit interface choices for parent, bridge and lag to device only
|
||||
params = {}
|
||||
if data.get('device'):
|
||||
params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
|
||||
if params:
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
|
||||
@@ -10,9 +10,11 @@ from utilities.forms import (
|
||||
__all__ = (
|
||||
'ComponentTemplateCreateForm',
|
||||
'DeviceComponentCreateForm',
|
||||
'DeviceTypeComponentCreateForm',
|
||||
'FrontPortCreateForm',
|
||||
'FrontPortTemplateCreateForm',
|
||||
'ModularComponentTemplateCreateForm',
|
||||
'ModuleBayCreateForm',
|
||||
'ModuleBayTemplateCreateForm',
|
||||
'VirtualChassisCreateForm',
|
||||
)
|
||||
|
||||
@@ -34,25 +36,34 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
|
||||
if self.cleaned_data['label_pattern']:
|
||||
name_pattern_count = len(self.cleaned_data['name_pattern'])
|
||||
label_pattern_count = len(self.cleaned_data['label_pattern'])
|
||||
if name_pattern_count != label_pattern_count:
|
||||
# Validate that all patterned fields generate an equal number of values
|
||||
patterned_fields = [
|
||||
field_name for field_name in self.fields if field_name.endswith('_pattern')
|
||||
]
|
||||
pattern_count = len(self.cleaned_data['name_pattern'])
|
||||
for field_name in patterned_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
|
||||
f'{label_pattern_count} labels will be generated. These counts must match.'
|
||||
field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are '
|
||||
f'expected.'
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
class DeviceTypeComponentCreateForm(ComponentCreateForm):
|
||||
class ComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned only to a DeviceType.
|
||||
"""
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
)
|
||||
field_order = ('device_type', 'name_pattern', 'label_pattern')
|
||||
|
||||
|
||||
class ComponentTemplateCreateForm(ComponentCreateForm):
|
||||
class ModularComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
|
||||
"""
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False
|
||||
@@ -71,7 +82,7 @@ class DeviceComponentCreateForm(ComponentCreateForm):
|
||||
field_order = ('device', 'name_pattern', 'label_pattern')
|
||||
|
||||
|
||||
class FrontPortTemplateCreateForm(DeviceTypeComponentCreateForm):
|
||||
class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
|
||||
rear_port_set = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='Rear ports',
|
||||
@@ -84,19 +95,27 @@ class FrontPortTemplateCreateForm(DeviceTypeComponentCreateForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
device_type = DeviceType.objects.get(
|
||||
pk=self.initial.get('device_type') or self.data.get('device_type')
|
||||
)
|
||||
# TODO: This needs better validation
|
||||
if 'device_type' in self.initial or self.data.get('device_type'):
|
||||
parent = DeviceType.objects.get(
|
||||
pk=self.initial.get('device_type') or self.data.get('device_type')
|
||||
)
|
||||
elif 'module_type' in self.initial or self.data.get('module_type'):
|
||||
parent = ModuleType.objects.get(
|
||||
pk=self.initial.get('module_type') or self.data.get('module_type')
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
# Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
|
||||
occupied_port_positions = [
|
||||
(front_port.rear_port_id, front_port.rear_port_position)
|
||||
for front_port in device_type.frontporttemplates.all()
|
||||
for front_port in parent.frontporttemplates.all()
|
||||
]
|
||||
|
||||
# Populate rear port choices
|
||||
choices = []
|
||||
rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
|
||||
rear_ports = parent.rearporttemplates.all()
|
||||
for rear_port in rear_ports:
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
if (rear_port.pk, i) not in occupied_port_positions:
|
||||
@@ -162,6 +181,24 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
|
||||
position_pattern = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
)
|
||||
field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
|
||||
|
||||
|
||||
class ModuleBayCreateForm(DeviceComponentCreateForm):
|
||||
position_pattern = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
)
|
||||
field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
|
||||
|
||||
|
||||
class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@@ -14,6 +14,31 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Rename any indexes left over from the old Module model (now InventoryItem) (#8656)
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
idx record;
|
||||
BEGIN
|
||||
FOR idx IN
|
||||
SELECT indexname AS old_name,
|
||||
replace(indexname, 'module', 'inventoryitem') AS new_name
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND
|
||||
tablename = 'dcim_inventoryitem' AND
|
||||
indexname LIKE 'dcim_module_%'
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'ALTER INDEX %I RENAME TO %I;',
|
||||
idx.old_name,
|
||||
idx.new_name
|
||||
);
|
||||
END LOOP;
|
||||
END$$;
|
||||
"""
|
||||
),
|
||||
|
||||
migrations.AlterModelOptions(
|
||||
name='consoleporttemplate',
|
||||
options={'ordering': ('device_type', 'module_type', '_name')},
|
||||
|
||||
@@ -804,10 +804,11 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
})
|
||||
|
||||
# Prevent 0U devices from being assigned to a specific position
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
if hasattr(self, 'device_type'):
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
|
||||
if self.rack:
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ def get_cabletermination_row_class(record):
|
||||
if record.mark_connected:
|
||||
return 'success'
|
||||
elif record.cable:
|
||||
return record.cable.get_status_class()
|
||||
return record.cable.get_status_color()
|
||||
return ''
|
||||
|
||||
|
||||
@@ -744,8 +744,8 @@ class DeviceModuleBayTable(ModuleBayTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ModuleBay
|
||||
fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'description', 'installed_module')
|
||||
fields = ('pk', 'id', 'name', 'label', 'position', 'installed_module', 'description', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
|
||||
|
||||
@@ -82,6 +82,10 @@ class SiteTable(NetBoxTable):
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name='ASN Count'
|
||||
)
|
||||
asns = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='ASNs'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
@@ -93,9 +97,9 @@ class SiteTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
|
||||
@@ -112,6 +112,11 @@ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
|
||||
#
|
||||
|
||||
CONSOLEPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
@@ -139,6 +144,11 @@ CONSOLEPORT_BUTTONS = """
|
||||
"""
|
||||
|
||||
CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
@@ -166,6 +176,11 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
"""
|
||||
|
||||
POWERPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
@@ -192,6 +207,11 @@ POWERPORT_BUTTONS = """
|
||||
"""
|
||||
|
||||
POWEROUTLET_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
@@ -214,10 +234,20 @@ POWEROUTLET_BUTTONS = """
|
||||
"""
|
||||
|
||||
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-sm btn-success" title="Add IP address">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.ipam.add_ipaddress or perms.dcim.add_inventoryitem %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Add">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">IP Address</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if record.link %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
@@ -240,7 +270,7 @@ INTERFACE_BUTTONS = """
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@@ -261,6 +291,11 @@ INTERFACE_BUTTONS = """
|
||||
"""
|
||||
|
||||
FRONTPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
@@ -293,6 +328,11 @@ FRONTPORT_BUTTONS = """
|
||||
"""
|
||||
|
||||
REARPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
|
||||
@@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10, description='foobar1'),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20, description='foobar1'),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', latitude=30, longitude=30),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
@@ -189,6 +189,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'longitude': [10, 20]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -317,8 +321,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
rack_roles = (
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00', description='foobar2'),
|
||||
RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
|
||||
)
|
||||
RackRole.objects.bulk_create(rack_roles)
|
||||
@@ -335,6 +339,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'color': ['ff0000', '00ff00']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Rack.objects.all()
|
||||
@@ -558,8 +566,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
reservations = (
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]),
|
||||
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]),
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
|
||||
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
|
||||
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
|
||||
)
|
||||
RackReservation.objects.bulk_create(reservations)
|
||||
@@ -592,6 +600,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_group(self):
|
||||
tenant_groups = TenantGroup.objects.all()[:2]
|
||||
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
|
||||
@@ -1302,8 +1314,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True),
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
@@ -1326,6 +1338,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'vm_role': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Platform.objects.all()
|
||||
@@ -1787,6 +1803,13 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_module_type(self):
|
||||
module_types = ModuleType.objects.all()[:2]
|
||||
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'module_type': [module_types[0].model, module_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_device(self):
|
||||
device_types = Device.objects.all()[:2]
|
||||
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
||||
@@ -1831,7 +1854,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -1850,6 +1874,20 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(device=devices[3], name='Console Server Port 1'),
|
||||
ConsoleServerPort(device=devices[3], name='Console Server Port 2'),
|
||||
@@ -1857,9 +1895,9 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
console_ports = (
|
||||
ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'),
|
||||
ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'),
|
||||
ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'),
|
||||
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
|
||||
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
|
||||
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
|
||||
)
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
@@ -1914,6 +1952,11 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
@@ -1958,7 +2001,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -1977,6 +2021,20 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
console_ports = (
|
||||
ConsolePort(device=devices[3], name='Console Server Port 1'),
|
||||
ConsolePort(device=devices[3], name='Console Server Port 2'),
|
||||
@@ -1984,9 +2042,9 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'),
|
||||
ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'),
|
||||
ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'),
|
||||
ConsoleServerPort(device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'),
|
||||
ConsoleServerPort(device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'),
|
||||
ConsoleServerPort(device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'),
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
@@ -2048,6 +2106,11 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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)
|
||||
@@ -2085,7 +2148,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -2104,6 +2168,20 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
power_outlets = (
|
||||
PowerOutlet(device=devices[3], name='Power Outlet 1'),
|
||||
PowerOutlet(device=devices[3], name='Power Outlet 2'),
|
||||
@@ -2111,9 +2189,9 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
|
||||
power_ports = (
|
||||
PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'),
|
||||
PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'),
|
||||
PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'),
|
||||
PowerPort(device=devices[0], module=modules[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'),
|
||||
PowerPort(device=devices[1], module=modules[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'),
|
||||
PowerPort(device=devices[2], module=modules[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
@@ -2183,6 +2261,11 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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)
|
||||
@@ -2220,7 +2303,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -2239,6 +2323,20 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
power_ports = (
|
||||
PowerPort(device=devices[3], name='Power Outlet 1'),
|
||||
PowerPort(device=devices[3], name='Power Outlet 2'),
|
||||
@@ -2246,9 +2344,9 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
power_outlets = (
|
||||
PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
|
||||
PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
|
||||
PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
|
||||
PowerOutlet(device=devices[0], module=modules[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
|
||||
PowerOutlet(device=devices[1], module=modules[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
|
||||
PowerOutlet(device=devices[2], module=modules[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
|
||||
@@ -2314,6 +2412,11 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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)
|
||||
@@ -2351,7 +2454,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -2370,6 +2474,20 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:1'),
|
||||
VRF(name='VRF 2', rd='65000:2'),
|
||||
@@ -2383,9 +2501,9 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
|
||||
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
|
||||
Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
|
||||
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
|
||||
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
|
||||
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
|
||||
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
|
||||
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
|
||||
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
|
||||
@@ -2525,6 +2643,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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(), 4)
|
||||
@@ -2603,7 +2726,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -2622,6 +2746,20 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
rear_ports = (
|
||||
RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=6),
|
||||
RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C, positions=6),
|
||||
@@ -2633,9 +2771,9 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
front_ports = (
|
||||
FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, rear_port=rear_ports[0], rear_port_position=1, description='First'),
|
||||
FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
|
||||
FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
|
||||
FrontPort(device=devices[0], module=modules[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, rear_port=rear_ports[0], rear_port_position=1, description='First'),
|
||||
FrontPort(device=devices[1], module=modules[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
|
||||
FrontPort(device=devices[2], module=modules[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
|
||||
FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1),
|
||||
FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1),
|
||||
@@ -2702,6 +2840,11 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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(), 4)
|
||||
@@ -2739,7 +2882,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
locations = (
|
||||
@@ -2758,10 +2902,24 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
rear_ports = (
|
||||
RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1, description='First'),
|
||||
RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2, description='Second'),
|
||||
RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3, description='Third'),
|
||||
RearPort(device=devices[0], module=modules[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1, description='First'),
|
||||
RearPort(device=devices[1], module=modules[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2, description='Second'),
|
||||
RearPort(device=devices[2], module=modules[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3, description='Third'),
|
||||
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
|
||||
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
|
||||
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
|
||||
@@ -2832,6 +2990,11 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[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(), 4)
|
||||
|
||||
@@ -50,23 +50,26 @@ class DeviceTypeComponentsView(DeviceComponentsView):
|
||||
return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
if self.viewname:
|
||||
return_url = reverse(self.viewname, kwargs={'pk': instance.pk})
|
||||
else:
|
||||
return_url = instance.get_absolute_url()
|
||||
return {
|
||||
'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
|
||||
'return_url': return_url,
|
||||
}
|
||||
context = super().get_extra_context(request, instance)
|
||||
context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ModuleTypeComponentsView(DeviceComponentsView):
|
||||
queryset = ModuleType.objects.all()
|
||||
template_name = 'dcim/moduletype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
context = super().get_extra_context(request, instance)
|
||||
context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
"""
|
||||
@@ -732,21 +735,25 @@ class ManufacturerView(generic.ObjectView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
|
||||
device_types = DeviceType.objects.restrict(request.user, 'view').filter(
|
||||
manufacturer=instance
|
||||
).annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
module_types = ModuleType.objects.restrict(request.user, 'view').filter(
|
||||
manufacturer=instance
|
||||
)
|
||||
inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
|
||||
manufacturer=instance
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
|
||||
devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',))
|
||||
devicetypes_table.configure(request)
|
||||
|
||||
return {
|
||||
'devicetypes_table': devicetypes_table,
|
||||
'inventory_item_count': inventory_items.count(),
|
||||
'module_type_count': module_types.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -858,6 +865,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
|
||||
child_model = ModuleBayTemplate
|
||||
table = tables.ModuleBayTemplateTable
|
||||
filterset = filtersets.ModuleBayTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_modulebays'
|
||||
|
||||
|
||||
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
|
||||
@@ -962,42 +970,49 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
|
||||
child_model = ConsolePortTemplate
|
||||
table = tables.ConsolePortTemplateTable
|
||||
filterset = filtersets.ConsolePortTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_consoleports'
|
||||
|
||||
|
||||
class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
|
||||
child_model = ConsoleServerPortTemplate
|
||||
table = tables.ConsoleServerPortTemplateTable
|
||||
filterset = filtersets.ConsoleServerPortTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_consoleserverports'
|
||||
|
||||
|
||||
class ModuleTypePowerPortsView(ModuleTypeComponentsView):
|
||||
child_model = PowerPortTemplate
|
||||
table = tables.PowerPortTemplateTable
|
||||
filterset = filtersets.PowerPortTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_powerports'
|
||||
|
||||
|
||||
class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
|
||||
child_model = PowerOutletTemplate
|
||||
table = tables.PowerOutletTemplateTable
|
||||
filterset = filtersets.PowerOutletTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_poweroutlets'
|
||||
|
||||
|
||||
class ModuleTypeInterfacesView(ModuleTypeComponentsView):
|
||||
child_model = InterfaceTemplate
|
||||
table = tables.InterfaceTemplateTable
|
||||
filterset = filtersets.InterfaceTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_interfaces'
|
||||
|
||||
|
||||
class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
|
||||
child_model = FrontPortTemplate
|
||||
table = tables.FrontPortTemplateTable
|
||||
filterset = filtersets.FrontPortTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_frontports'
|
||||
|
||||
|
||||
class ModuleTypeRearPortsView(ModuleTypeComponentsView):
|
||||
child_model = RearPortTemplate
|
||||
table = tables.RearPortTemplateTable
|
||||
filterset = filtersets.RearPortTemplateFilterSet
|
||||
viewname = 'dcim:moduletype_rearports'
|
||||
|
||||
|
||||
class ModuleTypeEditView(generic.ObjectEditView):
|
||||
@@ -1060,8 +1075,9 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePortTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.ConsolePortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class ConsolePortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1094,8 +1110,9 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPortTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.ConsoleServerPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1128,8 +1145,9 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PowerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPortTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.PowerPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class PowerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1162,8 +1180,9 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutletTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.PowerOutletTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class PowerOutletTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1196,8 +1215,9 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class InterfaceTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InterfaceTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.InterfaceTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class InterfaceTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1232,6 +1252,7 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPortTemplate.objects.all()
|
||||
form = forms.FrontPortTemplateCreateForm
|
||||
model_form = forms.FrontPortTemplateForm
|
||||
template_name = 'dcim/frontporttemplate_create.html'
|
||||
|
||||
def initialize_forms(self, request):
|
||||
form, model_form = super().initialize_forms(request)
|
||||
@@ -1272,8 +1293,9 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RearPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPortTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.RearPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class RearPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1306,8 +1328,10 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ModuleBayTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ModuleBayTemplate.objects.all()
|
||||
form = forms.DeviceTypeComponentCreateForm
|
||||
form = forms.ModuleBayTemplateCreateForm
|
||||
model_form = forms.ModuleBayTemplateForm
|
||||
template_name = 'dcim/modulebaytemplate_create.html'
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class ModuleBayTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1340,8 +1364,9 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceBayTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBayTemplate.objects.all()
|
||||
form = forms.DeviceTypeComponentCreateForm
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
model_form = forms.DeviceBayTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class DeviceBayTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1374,9 +1399,9 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItemTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
model_form = forms.InventoryItemTemplateForm
|
||||
template_name = 'dcim/inventoryitem_create.html'
|
||||
template_name = 'dcim/inventoryitemtemplate_create.html'
|
||||
|
||||
def alter_object(self, instance, request):
|
||||
# Set component (if any)
|
||||
@@ -1791,7 +1816,7 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class ConsolePortView(generic.ObjectView):
|
||||
@@ -1850,7 +1875,7 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class ConsoleServerPortView(generic.ObjectView):
|
||||
@@ -1909,7 +1934,7 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class PowerPortView(generic.ObjectView):
|
||||
@@ -1968,7 +1993,7 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class PowerOutletView(generic.ObjectView):
|
||||
@@ -2027,7 +2052,7 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class InterfaceView(generic.ObjectView):
|
||||
@@ -2149,7 +2174,7 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class FrontPortView(generic.ObjectView):
|
||||
@@ -2216,7 +2241,7 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class RearPortView(generic.ObjectView):
|
||||
@@ -2275,7 +2300,7 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class ModuleBayView(generic.ObjectView):
|
||||
@@ -2284,8 +2309,9 @@ class ModuleBayView(generic.ObjectView):
|
||||
|
||||
class ModuleBayCreateView(generic.ComponentCreateView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.ModuleBayCreateForm
|
||||
model_form = forms.ModuleBayForm
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class ModuleBayEditView(generic.ObjectEditView):
|
||||
@@ -2330,7 +2356,7 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class DeviceBayView(generic.ObjectView):
|
||||
@@ -2452,7 +2478,7 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class InventoryItemView(generic.ObjectView):
|
||||
@@ -2685,7 +2711,7 @@ class CableListView(generic.ObjectListView):
|
||||
filterset = filtersets.CableFilterSet
|
||||
filterset_form = forms.CableFilterForm
|
||||
table = tables.CableTable
|
||||
action_buttons = ('import', 'export')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class CableView(generic.ObjectView):
|
||||
@@ -2768,6 +2794,10 @@ class CableCreateView(generic.ObjectEditView):
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
# Always return a new instance
|
||||
return self.queryset.model()
|
||||
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
termination_a_type = url_kwargs.get('termination_a_type')
|
||||
termination_a_id = url_kwargs.get('termination_a_id')
|
||||
@@ -2848,7 +2878,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.ConsoleConnectionFilterForm
|
||||
table = tables.ConsoleConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
actions = ('export',)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -2862,7 +2892,7 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.PowerConnectionFilterForm
|
||||
table = tables.PowerConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
actions = ('export',)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -2876,7 +2906,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.InterfaceConnectionFilterForm
|
||||
table = tables.InterfaceConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
action_buttons = ('export',)
|
||||
actions = ('export',)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@ from extras.reports import get_report, get_reports, run_report
|
||||
from extras.scripts import get_script, get_scripts, run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.utils import copy_safe_request, count_related
|
||||
from . import serializers
|
||||
@@ -58,7 +58,7 @@ class ConfigContextQuerySetMixin:
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
class WebhookViewSet(ModelViewSet):
|
||||
class WebhookViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Webhook.objects.all()
|
||||
serializer_class = serializers.WebhookSerializer
|
||||
@@ -69,36 +69,18 @@ class WebhookViewSet(ModelViewSet):
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldViewSet(ModelViewSet):
|
||||
class CustomFieldViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CustomField.objects.all()
|
||||
serializer_class = serializers.CustomFieldSerializer
|
||||
filterset_class = filtersets.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomFieldModelViewSet(ModelViewSet):
|
||||
"""
|
||||
Include the applicable set of CustomFields in the ModelViewSet context.
|
||||
"""
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
# Gather all custom fields for the model
|
||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
custom_fields = content_type.custom_fields.all()
|
||||
|
||||
context = super().get_serializer_context()
|
||||
context.update({
|
||||
'custom_fields': custom_fields,
|
||||
})
|
||||
return context
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
||||
class CustomLinkViewSet(ModelViewSet):
|
||||
class CustomLinkViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CustomLink.objects.all()
|
||||
serializer_class = serializers.CustomLinkSerializer
|
||||
@@ -109,7 +91,7 @@ class CustomLinkViewSet(ModelViewSet):
|
||||
# Export templates
|
||||
#
|
||||
|
||||
class ExportTemplateViewSet(ModelViewSet):
|
||||
class ExportTemplateViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ExportTemplate.objects.all()
|
||||
serializer_class = serializers.ExportTemplateSerializer
|
||||
@@ -120,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
# Tags
|
||||
#
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
class TagViewSet(NetBoxModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
@@ -132,7 +114,7 @@ class TagViewSet(ModelViewSet):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentViewSet(ModelViewSet):
|
||||
class ImageAttachmentViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
@@ -143,7 +125,7 @@ class ImageAttachmentViewSet(ModelViewSet):
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
class JournalEntryViewSet(ModelViewSet):
|
||||
class JournalEntryViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = JournalEntry.objects.all()
|
||||
serializer_class = serializers.JournalEntrySerializer
|
||||
@@ -154,7 +136,7 @@ class JournalEntryViewSet(ModelViewSet):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextViewSet(ModelViewSet):
|
||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
||||
queryset = ConfigContext.objects.prefetch_related(
|
||||
'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -105,7 +105,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name']
|
||||
fields = ['id', 'content_type', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -179,14 +179,15 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(slug__icontains=value)
|
||||
Q(slug__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def _content_type(self, queryset, name, values):
|
||||
|
||||
@@ -72,9 +72,9 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
help_texts = {
|
||||
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
|
||||
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. '
|
||||
'Links which render as empty text will not be displayed.',
|
||||
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
|
||||
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>.',
|
||||
}
|
||||
|
||||
|
||||
|
||||
1
netbox/extras/tables/__init__.py
Normal file
1
netbox/extras/tables/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .tables import *
|
||||
@@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
|
||||
from extras.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from .models import *
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextTable',
|
||||
@@ -17,32 +18,6 @@ __all__ = (
|
||||
'WebhookTable',
|
||||
)
|
||||
|
||||
CONFIGCONTEXT_ACTIONS = """
|
||||
{% if perms.extras.change_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_FULL_NAME = """
|
||||
{% load helpers %}
|
||||
{{ record.user.get_full_name|placeholder }}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% else %}
|
||||
{{ record.object_repr }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_REQUEST_ID = """
|
||||
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
@@ -189,9 +164,8 @@ class ConfigContextTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
||||
'last_updated',
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
|
||||
'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
|
||||
@@ -225,7 +199,10 @@ class ObjectChangeTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ObjectChange
|
||||
fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
fields = (
|
||||
'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
|
||||
'actions',
|
||||
)
|
||||
|
||||
|
||||
class ObjectJournalTable(NetBoxTable):
|
||||
@@ -260,8 +237,8 @@ class JournalEntryTable(ObjectJournalTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = JournalEntry
|
||||
fields = (
|
||||
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
|
||||
'comments', 'actions'
|
||||
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
|
||||
25
netbox/extras/tables/template_code.py
Normal file
25
netbox/extras/tables/template_code.py
Normal file
@@ -0,0 +1,25 @@
|
||||
CONFIGCONTEXT_ACTIONS = """
|
||||
{% if perms.extras.change_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_FULL_NAME = """
|
||||
{% load helpers %}
|
||||
{{ record.user.get_full_name|placeholder }}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% else %}
|
||||
{{ record.object_repr }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_REQUEST_ID = """
|
||||
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
|
||||
"""
|
||||
@@ -42,7 +42,8 @@ def custom_links(context, obj):
|
||||
|
||||
# Pass select context data when rendering the CustomLink
|
||||
link_context = {
|
||||
'obj': obj,
|
||||
'object': obj,
|
||||
'obj': obj, # TODO: Remove in NetBox v3.5
|
||||
'debug': context.get('debug', False), # django.template.context_processors.debug
|
||||
'request': context['request'], # django.template.context_processors.request
|
||||
'user': context['user'], # django.contrib.auth.context_processors.auth
|
||||
|
||||
@@ -162,8 +162,8 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||
|
||||
export_templates = (
|
||||
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'),
|
||||
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'),
|
||||
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'),
|
||||
)
|
||||
ExportTemplate.objects.bulk_create(export_templates)
|
||||
@@ -176,6 +176,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'content_type': ContentType.objects.get(model='site').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
@@ -565,8 +569,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1', color='ff0000'),
|
||||
Tag(name='Tag 2', slug='tag-2', color='00ff00'),
|
||||
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
|
||||
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
|
||||
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
|
||||
)
|
||||
Tag.objects.bulk_create(tags)
|
||||
@@ -590,6 +594,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'color': ['ff0000', '00ff00']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type': ['dcim.site', 'circuits.provider']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -11,7 +11,7 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
@@ -269,7 +269,7 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
filterset_form = forms.ConfigContextFilterForm
|
||||
table = tables.ConfigContextTable
|
||||
action_buttons = ('add',)
|
||||
actions = ('add', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class ConfigContextView(generic.ObjectView):
|
||||
@@ -366,7 +366,7 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'extras/objectchange_list.html'
|
||||
action_buttons = ('export',)
|
||||
actions = ('export',)
|
||||
|
||||
|
||||
class ObjectChangeView(generic.ObjectView):
|
||||
@@ -458,7 +458,7 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
action_buttons = ('export',)
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
class JournalEntryView(generic.ObjectView):
|
||||
@@ -478,7 +478,7 @@ class JournalEntryEditView(generic.ObjectEditView):
|
||||
if not instance.assigned_object:
|
||||
return reverse('extras:journalentry_list')
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
viewname = get_viewname(obj, 'journal')
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
@@ -487,7 +487,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
def get_return_url(self, request, instance):
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
viewname = get_viewname(obj, 'journal')
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from ipam.choices import *
|
||||
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
|
||||
from ipam.models import *
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import PrimaryModelSerializer
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
@@ -20,7 +20,7 @@ from .nested_serializers import *
|
||||
# ASNs
|
||||
#
|
||||
|
||||
class ASNSerializer(PrimaryModelSerializer):
|
||||
class ASNSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
@@ -37,7 +37,7 @@ class ASNSerializer(PrimaryModelSerializer):
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFSerializer(PrimaryModelSerializer):
|
||||
class VRFSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
import_targets = SerializedPKRelatedField(
|
||||
@@ -67,7 +67,7 @@ class VRFSerializer(PrimaryModelSerializer):
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class RouteTargetSerializer(PrimaryModelSerializer):
|
||||
class RouteTargetSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
|
||||
@@ -82,7 +82,7 @@ class RouteTargetSerializer(PrimaryModelSerializer):
|
||||
# RIRs/aggregates
|
||||
#
|
||||
|
||||
class RIRSerializer(PrimaryModelSerializer):
|
||||
class RIRSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -94,7 +94,7 @@ class RIRSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class AggregateSerializer(PrimaryModelSerializer):
|
||||
class AggregateSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
rir = NestedRIRSerializer()
|
||||
@@ -113,7 +113,7 @@ class AggregateSerializer(PrimaryModelSerializer):
|
||||
# FHRP Groups
|
||||
#
|
||||
|
||||
class FHRPGroupSerializer(PrimaryModelSerializer):
|
||||
class FHRPGroupSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail')
|
||||
ip_addresses = NestedIPAddressSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -125,8 +125,8 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
|
||||
class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
|
||||
group = NestedFHRPGroupSerializer()
|
||||
interface_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
@@ -153,7 +153,7 @@ class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class RoleSerializer(PrimaryModelSerializer):
|
||||
class RoleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
@@ -166,7 +166,7 @@ class RoleSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class VLANGroupSerializer(PrimaryModelSerializer):
|
||||
class VLANGroupSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
|
||||
scope_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(
|
||||
@@ -196,7 +196,7 @@ class VLANGroupSerializer(PrimaryModelSerializer):
|
||||
return serializer(obj.scope, context=context).data
|
||||
|
||||
|
||||
class VLANSerializer(PrimaryModelSerializer):
|
||||
class VLANSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None)
|
||||
@@ -230,7 +230,7 @@ class AvailableVLANSerializer(serializers.Serializer):
|
||||
])
|
||||
|
||||
|
||||
class CreateAvailableVLANSerializer(PrimaryModelSerializer):
|
||||
class CreateAvailableVLANSerializer(NetBoxModelSerializer):
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
||||
@@ -251,7 +251,7 @@ class CreateAvailableVLANSerializer(PrimaryModelSerializer):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixSerializer(PrimaryModelSerializer):
|
||||
class PrefixSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
@@ -323,7 +323,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
|
||||
# IP ranges
|
||||
#
|
||||
|
||||
class IPRangeSerializer(PrimaryModelSerializer):
|
||||
class IPRangeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
@@ -345,7 +345,7 @@ class IPRangeSerializer(PrimaryModelSerializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressSerializer(PrimaryModelSerializer):
|
||||
class IPAddressSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
@@ -403,7 +403,7 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceTemplateSerializer(PrimaryModelSerializer):
|
||||
class ServiceTemplateSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
|
||||
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
|
||||
|
||||
@@ -415,7 +415,7 @@ class ServiceTemplateSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ServiceSerializer(PrimaryModelSerializer):
|
||||
class ServiceSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
|
||||
device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django_pglocks import advisory_lock
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
from netbox.api.views import ModelViewSet, ObjectValidationMixin
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
from netbox.config import get_config
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.utils import count_related
|
||||
@@ -32,13 +31,13 @@ class IPAMRootView(APIRootView):
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ASNViewSet(CustomFieldModelViewSet):
|
||||
class ASNViewSet(NetBoxModelViewSet):
|
||||
queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns'))
|
||||
serializer_class = serializers.ASNSerializer
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
class VRFViewSet(NetBoxModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
|
||||
'import_targets', 'export_targets', 'tags'
|
||||
).annotate(
|
||||
@@ -49,13 +48,13 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VRFFilterSet
|
||||
|
||||
|
||||
class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
class RouteTargetViewSet(NetBoxModelViewSet):
|
||||
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
|
||||
serializer_class = serializers.RouteTargetSerializer
|
||||
filterset_class = filtersets.RouteTargetFilterSet
|
||||
|
||||
|
||||
class RIRViewSet(CustomFieldModelViewSet):
|
||||
class RIRViewSet(NetBoxModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
).prefetch_related('tags')
|
||||
@@ -63,13 +62,13 @@ class RIRViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.RIRFilterSet
|
||||
|
||||
|
||||
class AggregateViewSet(CustomFieldModelViewSet):
|
||||
class AggregateViewSet(NetBoxModelViewSet):
|
||||
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
class RoleViewSet(CustomFieldModelViewSet):
|
||||
class RoleViewSet(NetBoxModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
@@ -78,7 +77,7 @@ class RoleViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.RoleFilterSet
|
||||
|
||||
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
class PrefixViewSet(NetBoxModelViewSet):
|
||||
queryset = Prefix.objects.prefetch_related(
|
||||
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
|
||||
)
|
||||
@@ -93,7 +92,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class IPRangeViewSet(CustomFieldModelViewSet):
|
||||
class IPRangeViewSet(NetBoxModelViewSet):
|
||||
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
|
||||
serializer_class = serializers.IPRangeSerializer
|
||||
filterset_class = filtersets.IPRangeFilterSet
|
||||
@@ -101,7 +100,7 @@ class IPRangeViewSet(CustomFieldModelViewSet):
|
||||
parent_model = IPRange # AvailableIPsMixin
|
||||
|
||||
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
class IPAddressViewSet(NetBoxModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
)
|
||||
@@ -109,20 +108,20 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.IPAddressFilterSet
|
||||
|
||||
|
||||
class FHRPGroupViewSet(CustomFieldModelViewSet):
|
||||
class FHRPGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
|
||||
serializer_class = serializers.FHRPGroupSerializer
|
||||
filterset_class = filtersets.FHRPGroupFilterSet
|
||||
brief_prefetch_fields = ('ip_addresses',)
|
||||
|
||||
|
||||
class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
|
||||
class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
|
||||
queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface')
|
||||
serializer_class = serializers.FHRPGroupAssignmentSerializer
|
||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||
|
||||
|
||||
class VLANGroupViewSet(CustomFieldModelViewSet):
|
||||
class VLANGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
).prefetch_related('tags')
|
||||
@@ -130,7 +129,7 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VLANGroupFilterSet
|
||||
|
||||
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
class VLANViewSet(NetBoxModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
@@ -140,13 +139,13 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VLANFilterSet
|
||||
|
||||
|
||||
class ServiceTemplateViewSet(CustomFieldModelViewSet):
|
||||
class ServiceTemplateViewSet(NetBoxModelViewSet):
|
||||
queryset = ServiceTemplate.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.ServiceTemplateSerializer
|
||||
filterset_class = filtersets.ServiceTemplateFilterSet
|
||||
|
||||
|
||||
class ServiceViewSet(CustomFieldModelViewSet):
|
||||
class ServiceViewSet(NetBoxModelViewSet):
|
||||
queryset = Service.objects.prefetch_related(
|
||||
'device', 'virtual_machine', 'tags', 'ipaddresses'
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ class IPAddressFamilyChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class PrefixStatusChoices(ChoiceSet):
|
||||
key = 'ipam.Prefix.status'
|
||||
key = 'Prefix.status'
|
||||
|
||||
STATUS_CONTAINER = 'container'
|
||||
STATUS_ACTIVE = 'active'
|
||||
@@ -37,7 +37,7 @@ class PrefixStatusChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class IPRangeStatusChoices(ChoiceSet):
|
||||
key = 'ipam.IPRange.status'
|
||||
key = 'IPRange.status'
|
||||
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_RESERVED = 'reserved'
|
||||
@@ -55,7 +55,7 @@ class IPRangeStatusChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class IPAddressStatusChoices(ChoiceSet):
|
||||
key = 'ipam.IPAddress.status'
|
||||
key = 'IPAddress.status'
|
||||
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_RESERVED = 'reserved'
|
||||
@@ -134,7 +134,7 @@ class FHRPGroupAuthTypeChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class VLANStatusChoices(ChoiceSet):
|
||||
key = 'ipam.VLAN.status'
|
||||
key = 'VLAN.status'
|
||||
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_RESERVED = 'reserved'
|
||||
@@ -155,8 +155,10 @@ class ServiceProtocolChoices(ChoiceSet):
|
||||
|
||||
PROTOCOL_TCP = 'tcp'
|
||||
PROTOCOL_UDP = 'udp'
|
||||
PROTOCOL_SCTP = 'sctp'
|
||||
|
||||
CHOICES = (
|
||||
(PROTOCOL_TCP, 'TCP'),
|
||||
(PROTOCOL_UDP, 'UDP'),
|
||||
(PROTOCOL_SCTP, 'SCTP'),
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique']
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
@@ -115,7 +115,7 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RouteTarget
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
|
||||
class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
@@ -151,7 +151,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'date_added']
|
||||
fields = ['id', 'date_added', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -199,7 +199,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ASN
|
||||
fields = ['id', 'asn']
|
||||
fields = ['id', 'asn', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -220,7 +220,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
@@ -348,7 +348,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'is_pool', 'mark_utilized']
|
||||
fields = ['id', 'is_pool', 'mark_utilized', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -453,7 +453,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = ['id']
|
||||
fields = ['id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -828,7 +828,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'vid', 'name']
|
||||
fields = ['id', 'vid', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -900,7 +900,7 @@ class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'name', 'protocol']
|
||||
fields = ['id', 'name', 'protocol', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -26,8 +26,8 @@ class FHRPGroupTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
interface_count = tables.Column(
|
||||
verbose_name='Interfaces'
|
||||
member_count = tables.Column(
|
||||
verbose_name='Members'
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:fhrpgroup_list'
|
||||
@@ -36,10 +36,10 @@ class FHRPGroupTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = FHRPGroup
|
||||
fields = (
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count')
|
||||
|
||||
|
||||
class FHRPGroupAssignmentTable(NetBoxTable):
|
||||
|
||||
@@ -112,6 +112,10 @@ class ASNTable(NetBoxTable):
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Site Count'
|
||||
)
|
||||
sites = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='Sites'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
@@ -122,8 +126,8 @@ class ASNTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASN
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated',
|
||||
'actions',
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant')
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
]
|
||||
|
||||
asns = (
|
||||
ASN(asn=64512, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
|
||||
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
|
||||
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
|
||||
@@ -86,6 +86,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VRF.objects.all()
|
||||
@@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False),
|
||||
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False),
|
||||
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False, description='foobar1'),
|
||||
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False, description='foobar2'),
|
||||
VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
|
||||
VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
|
||||
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
|
||||
@@ -174,6 +178,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RouteTarget.objects.all()
|
||||
@@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
route_targets = (
|
||||
RouteTarget(name='65000:1001', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1002', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
|
||||
RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
|
||||
RouteTarget(name='65000:1003', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1004', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:2001', tenant=tenants[1]),
|
||||
@@ -256,6 +264,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RIR.objects.all()
|
||||
@@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
aggregates = (
|
||||
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'),
|
||||
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'),
|
||||
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01', description='foobar1'),
|
||||
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02', description='foobar2'),
|
||||
Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'),
|
||||
Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'),
|
||||
Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'),
|
||||
@@ -340,6 +352,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'date_added': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Test for multiple values
|
||||
def test_prefix(self):
|
||||
params = {'prefix': '10.1.0.0/16'}
|
||||
@@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
roles = (
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 1', slug='role-1', description='foobar1'),
|
||||
Role(name='Role 2', slug='role-2', description='foobar2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
)
|
||||
Role.objects.bulk_create(roles)
|
||||
@@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'slug': ['role-1', 'role-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Prefix.objects.all()
|
||||
@@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
prefixes = (
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
|
||||
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
@@ -601,6 +621,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = IPRange.objects.all()
|
||||
@@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
ip_ranges = (
|
||||
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
|
||||
IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
|
||||
IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
@@ -692,6 +716,10 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = IPAddress.objects.all()
|
||||
@@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
vlans = (
|
||||
# Create one VLAN per VLANGroup
|
||||
VLAN(vid=1, name='Region 1', group=groups[0]),
|
||||
VLAN(vid=2, name='Region 2', group=groups[1]),
|
||||
VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
|
||||
VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
|
||||
VLAN(vid=3, name='Region 3', group=groups[2]),
|
||||
VLAN(vid=4, name='Site Group 1', group=groups[3]),
|
||||
VLAN(vid=5, name='Site Group 2', group=groups[4]),
|
||||
@@ -1271,6 +1299,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
roles = Role.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
@@ -1366,8 +1398,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
services = (
|
||||
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
|
||||
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
|
||||
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001], description='foobar1'),
|
||||
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002], description='foobar2'),
|
||||
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
|
||||
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
|
||||
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
|
||||
@@ -1383,6 +1415,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_port(self):
|
||||
params = {'port': '1001'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -86,14 +86,17 @@ def add_available_vlans(vlans, vlan_group=None):
|
||||
"""
|
||||
Create fake records for all gaps between used VLANs
|
||||
"""
|
||||
min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN
|
||||
max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX
|
||||
|
||||
if not vlans:
|
||||
return [{
|
||||
'vid': VLAN_VID_MIN,
|
||||
'vid': min_vid,
|
||||
'vlan_group': vlan_group,
|
||||
'available': VLAN_VID_MAX - VLAN_VID_MIN + 1
|
||||
'available': max_vid - min_vid + 1
|
||||
}]
|
||||
|
||||
prev_vid = VLAN_VID_MAX
|
||||
prev_vid = max_vid
|
||||
new_vlans = []
|
||||
for vlan in vlans:
|
||||
if vlan.vid - prev_vid > 1:
|
||||
@@ -104,17 +107,17 @@ def add_available_vlans(vlans, vlan_group=None):
|
||||
})
|
||||
prev_vid = vlan.vid
|
||||
|
||||
if vlans[0].vid > VLAN_VID_MIN:
|
||||
if vlans[0].vid > min_vid:
|
||||
new_vlans.append({
|
||||
'vid': VLAN_VID_MIN,
|
||||
'vid': min_vid,
|
||||
'vlan_group': vlan_group,
|
||||
'available': vlans[0].vid - VLAN_VID_MIN,
|
||||
'available': vlans[0].vid - min_vid,
|
||||
})
|
||||
if prev_vid < VLAN_VID_MAX:
|
||||
if prev_vid < max_vid:
|
||||
new_vlans.append({
|
||||
'vid': prev_vid + 1,
|
||||
'vlan_group': vlan_group,
|
||||
'available': VLAN_VID_MAX - prev_vid,
|
||||
'available': max_vid - prev_vid,
|
||||
})
|
||||
|
||||
vlans = list(vlans) + new_vlans
|
||||
|
||||
@@ -1080,7 +1080,6 @@ class ServiceListView(generic.ObjectListView):
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
filterset_form = forms.ServiceFilterForm
|
||||
table = tables.ServiceTable
|
||||
action_buttons = ('import', 'export')
|
||||
|
||||
|
||||
class ServiceView(generic.ObjectView):
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import ManyToManyField
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CreateOnlyDefault
|
||||
|
||||
from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.utils import dict_to_filter_params
|
||||
|
||||
|
||||
class BaseModelSerializer(serializers.ModelSerializer):
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_display(self, obj):
|
||||
return str(obj)
|
||||
|
||||
|
||||
class ValidatedModelSerializer(BaseModelSerializer):
|
||||
"""
|
||||
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
def validate(self, data):
|
||||
|
||||
# Remove custom fields data and tags (if any) prior to model validation
|
||||
attrs = data.copy()
|
||||
attrs.pop('custom_fields', None)
|
||||
attrs.pop('tags', None)
|
||||
|
||||
# Skip ManyToManyFields
|
||||
for field in self.Meta.model._meta.get_fields():
|
||||
if isinstance(field, ManyToManyField):
|
||||
attrs.pop(field.name, None)
|
||||
|
||||
# Run clean() on an instance of the model
|
||||
if self.instance is None:
|
||||
instance = self.Meta.model(**attrs)
|
||||
else:
|
||||
instance = self.instance
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance.full_clean()
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
"""
|
||||
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
custom_fields = CustomFieldsDataField(
|
||||
source='custom_field_data',
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance is not None:
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(content_types=content_type)
|
||||
|
||||
# Populate CustomFieldValues for each instance from database
|
||||
if type(self.instance) in (list, tuple):
|
||||
for obj in self.instance:
|
||||
self._populate_custom_fields(obj, fields)
|
||||
else:
|
||||
self._populate_custom_fields(self.instance, fields)
|
||||
|
||||
def _populate_custom_fields(self, instance, custom_fields):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
instance.custom_fields[field.name] = instance.cf.get(field.name)
|
||||
|
||||
|
||||
#
|
||||
# Nested serializers
|
||||
#
|
||||
|
||||
class WritableNestedSerializer(BaseModelSerializer):
|
||||
"""
|
||||
Returns a nested representation of an object on read, but accepts only a primary key on write.
|
||||
"""
|
||||
def to_internal_value(self, data):
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
# Dictionary of related object attributes
|
||||
if isinstance(data, dict):
|
||||
params = dict_to_filter_params(data)
|
||||
queryset = self.Meta.model.objects
|
||||
try:
|
||||
return queryset.get(**params)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(
|
||||
"Related object not found using the provided attributes: {}".format(params)
|
||||
)
|
||||
except MultipleObjectsReturned:
|
||||
raise ValidationError(
|
||||
"Multiple objects match the provided attributes: {}".format(params)
|
||||
)
|
||||
except FieldError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
# Integer PK of related object
|
||||
if isinstance(data, int):
|
||||
pk = data
|
||||
else:
|
||||
try:
|
||||
# PK might have been mistakenly passed as a string
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
"unrecognized value: {}".format(data)
|
||||
)
|
||||
|
||||
# Look up object by PK
|
||||
queryset = self.Meta.model.objects
|
||||
try:
|
||||
return queryset.get(pk=int(data))
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(
|
||||
"Related object not found using the provided numeric ID: {}".format(pk)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Nested tags serialization
|
||||
#
|
||||
|
||||
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
|
||||
class NestedTagSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'url', 'display', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
#
|
||||
# Base model serializers
|
||||
#
|
||||
|
||||
class PrimaryModelSerializer(CustomFieldModelSerializer):
|
||||
"""
|
||||
Adds support for custom fields and tags.
|
||||
"""
|
||||
tags = NestedTagSerializer(many=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
instance = super().create(validated_data)
|
||||
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# Cache tags on instance for change logging
|
||||
instance._tags = tags or []
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set([t.name for t in tags])
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class NestedGroupModelSerializer(PrimaryModelSerializer):
|
||||
"""
|
||||
Extends PrimaryModelSerializer to include MPTT support.
|
||||
"""
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
|
||||
class BulkOperationSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
27
netbox/netbox/api/serializers/__init__.py
Normal file
27
netbox/netbox/api/serializers/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .base import *
|
||||
from .features import *
|
||||
from .nested import *
|
||||
|
||||
|
||||
#
|
||||
# Base model serializers
|
||||
#
|
||||
|
||||
class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer):
|
||||
"""
|
||||
Adds support for custom fields and tags.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NestedGroupModelSerializer(NetBoxModelSerializer):
|
||||
"""
|
||||
Extends PrimaryModelSerializer to include MPTT support.
|
||||
"""
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
|
||||
class BulkOperationSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
43
netbox/netbox/api/serializers/base.py
Normal file
43
netbox/netbox/api/serializers/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.db.models import ManyToManyField
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = (
|
||||
'BaseModelSerializer',
|
||||
'ValidatedModelSerializer',
|
||||
)
|
||||
|
||||
|
||||
class BaseModelSerializer(serializers.ModelSerializer):
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_display(self, obj):
|
||||
return str(obj)
|
||||
|
||||
|
||||
class ValidatedModelSerializer(BaseModelSerializer):
|
||||
"""
|
||||
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
def validate(self, data):
|
||||
|
||||
# Remove custom fields data and tags (if any) prior to model validation
|
||||
attrs = data.copy()
|
||||
attrs.pop('custom_fields', None)
|
||||
attrs.pop('tags', None)
|
||||
|
||||
# Skip ManyToManyFields
|
||||
for field in self.Meta.model._meta.get_fields():
|
||||
if isinstance(field, ManyToManyField):
|
||||
attrs.pop(field.name, None)
|
||||
|
||||
# Run clean() on an instance of the model
|
||||
if self.instance is None:
|
||||
instance = self.Meta.model(**attrs)
|
||||
else:
|
||||
instance = self.instance
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance.full_clean()
|
||||
|
||||
return data
|
||||
80
netbox/netbox/api/serializers/features.py
Normal file
80
netbox/netbox/api/serializers/features.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import CreateOnlyDefault
|
||||
|
||||
from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
|
||||
from extras.models import CustomField
|
||||
from .nested import NestedTagSerializer
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldModelSerializer',
|
||||
'TaggableModelSerializer',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures
|
||||
that custom field data is populated upon initialization.
|
||||
"""
|
||||
custom_fields = CustomFieldsDataField(
|
||||
source='custom_field_data',
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance is not None:
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(content_types=content_type)
|
||||
|
||||
# Populate custom field values for each instance from database
|
||||
if type(self.instance) in (list, tuple):
|
||||
for obj in self.instance:
|
||||
self._populate_custom_fields(obj, fields)
|
||||
else:
|
||||
self._populate_custom_fields(self.instance, fields)
|
||||
|
||||
def _populate_custom_fields(self, instance, custom_fields):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
instance.custom_fields[field.name] = instance.cf.get(field.name)
|
||||
|
||||
|
||||
class TaggableModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
Introduces support for Tag assignment. Adds `tags` serialization, and handles tag assignment
|
||||
on create() and update().
|
||||
"""
|
||||
tags = NestedTagSerializer(many=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
instance = super().create(validated_data)
|
||||
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# Cache tags on instance for change logging
|
||||
instance._tags = tags or []
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set([t.name for t in tags])
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
62
netbox/netbox/api/serializers/nested.py
Normal file
62
netbox/netbox/api/serializers/nested.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from extras.models import Tag
|
||||
from utilities.utils import dict_to_filter_params
|
||||
from .base import BaseModelSerializer
|
||||
|
||||
__all__ = (
|
||||
'NestedTagSerializer',
|
||||
'WritableNestedSerializer',
|
||||
)
|
||||
|
||||
|
||||
class WritableNestedSerializer(BaseModelSerializer):
|
||||
"""
|
||||
Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a
|
||||
dictionary of attributes which can be used to uniquely identify the related object. This class should be
|
||||
subclassed to return a full representation of the related object on read.
|
||||
"""
|
||||
def to_internal_value(self, data):
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
# Dictionary of related object attributes
|
||||
if isinstance(data, dict):
|
||||
params = dict_to_filter_params(data)
|
||||
queryset = self.Meta.model.objects
|
||||
try:
|
||||
return queryset.get(**params)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided attributes: {params}")
|
||||
except MultipleObjectsReturned:
|
||||
raise ValidationError(f"Multiple objects match the provided attributes: {params}")
|
||||
except FieldError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
# Integer PK of related object
|
||||
try:
|
||||
# Cast as integer in case a PK was mistakenly sent as a string
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
f"unrecognized value: {data}"
|
||||
)
|
||||
|
||||
# Look up object by PK
|
||||
try:
|
||||
return self.Meta.model.objects.get(pk=pk)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
|
||||
|
||||
|
||||
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
|
||||
class NestedTagSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'url', 'display', 'name', 'slug', 'color']
|
||||
@@ -1,292 +1,17 @@
|
||||
import logging
|
||||
import platform
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import __version__ as DJANGO_VERSION
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
|
||||
from rq.worker import Worker
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from netbox.api import BulkOperationSerializer
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
HTTP_ACTIONS = {
|
||||
'GET': 'view',
|
||||
'OPTIONS': None,
|
||||
'HEAD': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'DELETE': 'delete',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Mixins
|
||||
#
|
||||
|
||||
class BulkUpdateModelMixin:
|
||||
"""
|
||||
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
||||
or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
|
||||
For example:
|
||||
|
||||
PATCH /api/dcim/sites/
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"name": "New name"
|
||||
},
|
||||
{
|
||||
"id": 456,
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
"""
|
||||
def bulk_update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop('partial', False)
|
||||
serializer = BulkOperationSerializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
qs = self.get_queryset().filter(
|
||||
pk__in=[o['id'] for o in serializer.data]
|
||||
)
|
||||
|
||||
# Map update data by object ID
|
||||
update_data = {
|
||||
obj.pop('id'): obj for obj in request.data
|
||||
}
|
||||
|
||||
data = self.perform_bulk_update(qs, update_data, partial=partial)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_bulk_update(self, objects, update_data, partial):
|
||||
with transaction.atomic():
|
||||
data_list = []
|
||||
for obj in objects:
|
||||
data = update_data.get(obj.id)
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
serializer = self.get_serializer(obj, data=data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
data_list.append(serializer.data)
|
||||
|
||||
return data_list
|
||||
|
||||
def bulk_partial_update(self, request, *args, **kwargs):
|
||||
kwargs['partial'] = True
|
||||
return self.bulk_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class BulkDestroyModelMixin:
|
||||
"""
|
||||
Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
|
||||
or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
|
||||
|
||||
DELETE /api/dcim/sites/
|
||||
[
|
||||
{"id": 123},
|
||||
{"id": 456}
|
||||
]
|
||||
"""
|
||||
def bulk_destroy(self, request, *args, **kwargs):
|
||||
serializer = BulkOperationSerializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
qs = self.get_queryset().filter(
|
||||
pk__in=[o['id'] for o in serializer.data]
|
||||
)
|
||||
|
||||
self.perform_bulk_destroy(qs)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def perform_bulk_destroy(self, objects):
|
||||
with transaction.atomic():
|
||||
for obj in objects:
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
self.perform_destroy(obj)
|
||||
|
||||
|
||||
class ObjectValidationMixin:
|
||||
|
||||
def _validate_objects(self, instance):
|
||||
"""
|
||||
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
|
||||
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
|
||||
"""
|
||||
if type(instance) is list:
|
||||
# Check that all instances are still included in the view's queryset
|
||||
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
|
||||
if conforming_count != len(instance):
|
||||
raise ObjectDoesNotExist
|
||||
else:
|
||||
# Check that the instance is matched by the view's queryset
|
||||
self.queryset.get(pk=instance.pk)
|
||||
|
||||
|
||||
#
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
|
||||
"""
|
||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||
"""
|
||||
brief = False
|
||||
brief_prefetch_fields = []
|
||||
|
||||
def get_object_with_snapshot(self):
|
||||
"""
|
||||
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
|
||||
record the "before" data in the changelog.
|
||||
"""
|
||||
obj = super().get_object()
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
return obj
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
if isinstance(kwargs.get('data', {}), list):
|
||||
kwargs['many'] = True
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
||||
if self.brief:
|
||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||
try:
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
logger.debug(f"Using serializer {serializer}")
|
||||
return serializer
|
||||
except SerializerNotFound:
|
||||
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
|
||||
|
||||
# Fall back to the hard-coded serializer class
|
||||
logger.debug(f"Using serializer {self.serializer_class}")
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
def initialize_request(self, request, *args, **kwargs):
|
||||
# Check if brief=True has been passed
|
||||
if request.method == 'GET' and request.GET.get('brief'):
|
||||
self.brief = True
|
||||
|
||||
return super().initialize_request(request, *args, **kwargs)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return
|
||||
|
||||
# Restrict the view's QuerySet to allow only the permitted objects
|
||||
action = HTTP_ACTIONS[request.method]
|
||||
if action:
|
||||
self.queryset = self.queryset.restrict(request.user, action)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
except ProtectedError as e:
|
||||
protected_objects = list(e.protected_objects)
|
||||
msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
|
||||
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
|
||||
logger.warning(msg)
|
||||
return self.finalize_response(
|
||||
request,
|
||||
Response({'detail': msg}, status=409),
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return et.render_to_response(queryset)
|
||||
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Creating new {model._meta.verbose_name}")
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||
self.get_object = self.get_object_with_snapshot
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||
self.get_object = self.get_object_with_snapshot
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
#
|
||||
# Views
|
||||
#
|
||||
|
||||
class APIRootView(APIView):
|
||||
"""
|
||||
|
||||
182
netbox/netbox/api/viewsets/__init__.py
Normal file
182
netbox/netbox/api/viewsets/__init__.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from utilities.api import get_serializer_for_model
|
||||
from .mixins import *
|
||||
|
||||
__all__ = (
|
||||
'NetBoxModelViewSet',
|
||||
)
|
||||
|
||||
HTTP_ACTIONS = {
|
||||
'GET': 'view',
|
||||
'OPTIONS': None,
|
||||
'HEAD': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'DELETE': 'delete',
|
||||
}
|
||||
|
||||
|
||||
class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet):
|
||||
"""
|
||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||
"""
|
||||
brief = False
|
||||
brief_prefetch_fields = []
|
||||
|
||||
def get_object_with_snapshot(self):
|
||||
"""
|
||||
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
|
||||
record the "before" data in the changelog.
|
||||
"""
|
||||
obj = super().get_object()
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
return obj
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
if isinstance(kwargs.get('data', {}), list):
|
||||
kwargs['many'] = True
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
||||
if self.brief:
|
||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||
try:
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
logger.debug(f"Using serializer {serializer}")
|
||||
return serializer
|
||||
except SerializerNotFound:
|
||||
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
|
||||
|
||||
# Fall back to the hard-coded serializer class
|
||||
logger.debug(f"Using serializer {self.serializer_class}")
|
||||
return self.serializer_class
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
For models which support custom fields, populate the `custom_fields` context.
|
||||
"""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
if hasattr(self.queryset.model, 'custom_fields'):
|
||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
context.update({
|
||||
'custom_fields': content_type.custom_fields.all(),
|
||||
})
|
||||
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
def initialize_request(self, request, *args, **kwargs):
|
||||
# Check if brief=True has been passed
|
||||
if request.method == 'GET' and request.GET.get('brief'):
|
||||
self.brief = True
|
||||
|
||||
return super().initialize_request(request, *args, **kwargs)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return
|
||||
|
||||
# Restrict the view's QuerySet to allow only the permitted objects
|
||||
action = HTTP_ACTIONS[request.method]
|
||||
if action:
|
||||
self.queryset = self.queryset.restrict(request.user, action)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
except ProtectedError as e:
|
||||
protected_objects = list(e.protected_objects)
|
||||
msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
|
||||
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
|
||||
logger.warning(msg)
|
||||
return self.finalize_response(
|
||||
request,
|
||||
Response({'detail': msg}, status=409),
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return et.render_to_response(queryset)
|
||||
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Creating new {model._meta.verbose_name}")
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||
self.get_object = self.get_object_with_snapshot
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||
self.get_object = self.get_object_with_snapshot
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
113
netbox/netbox/api/viewsets/mixins.py
Normal file
113
netbox/netbox/api/viewsets/mixins.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from netbox.api.serializers import BulkOperationSerializer
|
||||
|
||||
__all__ = (
|
||||
'BulkUpdateModelMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'ObjectValidationMixin',
|
||||
)
|
||||
|
||||
|
||||
class BulkUpdateModelMixin:
|
||||
"""
|
||||
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
||||
or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
|
||||
For example:
|
||||
|
||||
PATCH /api/dcim/sites/
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"name": "New name"
|
||||
},
|
||||
{
|
||||
"id": 456,
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
"""
|
||||
def bulk_update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop('partial', False)
|
||||
serializer = BulkOperationSerializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
qs = self.get_queryset().filter(
|
||||
pk__in=[o['id'] for o in serializer.data]
|
||||
)
|
||||
|
||||
# Map update data by object ID
|
||||
update_data = {
|
||||
obj.pop('id'): obj for obj in request.data
|
||||
}
|
||||
|
||||
data = self.perform_bulk_update(qs, update_data, partial=partial)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_bulk_update(self, objects, update_data, partial):
|
||||
with transaction.atomic():
|
||||
data_list = []
|
||||
for obj in objects:
|
||||
data = update_data.get(obj.id)
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
serializer = self.get_serializer(obj, data=data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
data_list.append(serializer.data)
|
||||
|
||||
return data_list
|
||||
|
||||
def bulk_partial_update(self, request, *args, **kwargs):
|
||||
kwargs['partial'] = True
|
||||
return self.bulk_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class BulkDestroyModelMixin:
|
||||
"""
|
||||
Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
|
||||
or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
|
||||
|
||||
DELETE /api/dcim/sites/
|
||||
[
|
||||
{"id": 123},
|
||||
{"id": 456}
|
||||
]
|
||||
"""
|
||||
def bulk_destroy(self, request, *args, **kwargs):
|
||||
serializer = BulkOperationSerializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
qs = self.get_queryset().filter(
|
||||
pk__in=[o['id'] for o in serializer.data]
|
||||
)
|
||||
|
||||
self.perform_bulk_destroy(qs)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def perform_bulk_destroy(self, objects):
|
||||
with transaction.atomic():
|
||||
for obj in objects:
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
self.perform_destroy(obj)
|
||||
|
||||
|
||||
class ObjectValidationMixin:
|
||||
|
||||
def _validate_objects(self, instance):
|
||||
"""
|
||||
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
|
||||
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
|
||||
"""
|
||||
if type(instance) is list:
|
||||
# Check that all instances are still included in the view's queryset
|
||||
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
|
||||
if conforming_count != len(instance):
|
||||
raise ObjectDoesNotExist
|
||||
else:
|
||||
# Check that the instance is matched by the view's queryset
|
||||
self.queryset.get(pk=instance.pk)
|
||||
@@ -19,7 +19,7 @@ from netbox.config import PARAMS
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.2.0-beta1'
|
||||
VERSION = '3.2.0-beta2'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
@@ -217,7 +217,7 @@ class ChoiceFieldColumn(tables.Column):
|
||||
|
||||
# Determine the background color to use (try calling object.get_FOO_color())
|
||||
try:
|
||||
bg_color = getattr(record, f'get_{bound_column.name}_color')()
|
||||
bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
|
||||
except AttributeError:
|
||||
bg_color = self.DEFAULT_BG_COLOR
|
||||
|
||||
@@ -361,27 +361,35 @@ class CustomFieldColumn(tables.Column):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _likify_item(item):
|
||||
if hasattr(item, 'get_absolute_url'):
|
||||
return f'<a href="{item.get_absolute_url()}">{item}</a>'
|
||||
return item
|
||||
|
||||
def render(self, value):
|
||||
if isinstance(value, list):
|
||||
return ', '.join(v for v in value)
|
||||
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
|
||||
return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
|
||||
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
|
||||
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
|
||||
elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
return mark_safe(f'<a href="{value}">{value}</a>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
return ', '.join(v for v in value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
return mark_safe(', '.join([
|
||||
self._likify_item(obj) for obj in self.customfield.deserialize(value)
|
||||
]))
|
||||
if value is not None:
|
||||
obj = self.customfield.deserialize(value)
|
||||
if hasattr(obj, 'get_absolute_url'):
|
||||
return mark_safe(f'<a href="{obj.get_absolute_url}">{obj}</a>')
|
||||
return obj
|
||||
return mark_safe(self._likify_item(obj))
|
||||
return self.default
|
||||
|
||||
def value(self, value):
|
||||
if isinstance(value, list):
|
||||
return ','.join(v for v in value)
|
||||
return ','.join(str(v) for v in self.customfield.deserialize(value))
|
||||
if value is not None:
|
||||
return value
|
||||
return self.customfield.deserialize(value)
|
||||
return self.default
|
||||
|
||||
|
||||
@@ -399,7 +407,10 @@ class CustomLinkColumn(tables.Column):
|
||||
|
||||
def render(self, record):
|
||||
try:
|
||||
rendered = self.customlink.render({'obj': record})
|
||||
rendered = self.customlink.render({
|
||||
'object': record,
|
||||
'obj': record, # TODO: Remove in NetBox v3.5
|
||||
})
|
||||
if rendered:
|
||||
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
|
||||
except Exception as e:
|
||||
@@ -408,7 +419,10 @@ class CustomLinkColumn(tables.Column):
|
||||
|
||||
def value(self, record):
|
||||
try:
|
||||
rendered = self.customlink.render({'obj': record})
|
||||
rendered = self.customlink.render({
|
||||
'object': record,
|
||||
'obj': record, # TODO: Remove in NetBox v3.5
|
||||
})
|
||||
if rendered:
|
||||
return rendered['link']
|
||||
except Exception:
|
||||
|
||||
@@ -41,40 +41,37 @@ class BaseTable(tables.Table):
|
||||
if self.empty_text is None:
|
||||
self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
|
||||
|
||||
# Hide non-default columns
|
||||
default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), *self.exempt_columns]
|
||||
for column in self.columns:
|
||||
if column.name not in default_columns:
|
||||
self.columns.hide(column.name)
|
||||
|
||||
# Apply custom column ordering for user
|
||||
# Determine the table columns to display by checking the following:
|
||||
# 1. User's configuration for the table
|
||||
# 2. Meta.default_columns
|
||||
# 3. Meta.fields
|
||||
selected_columns = None
|
||||
if user is not None and not isinstance(user, AnonymousUser):
|
||||
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
|
||||
if selected_columns:
|
||||
if not selected_columns:
|
||||
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
|
||||
|
||||
# Show only persistent or selected columns
|
||||
for name, column in self.columns.items():
|
||||
if name in [*self.exempt_columns, *selected_columns]:
|
||||
self.columns.show(name)
|
||||
else:
|
||||
self.columns.hide(name)
|
||||
# Hide non-selected columns which are not exempt
|
||||
for column in self.columns:
|
||||
if column.name not in [*selected_columns, *self.exempt_columns]:
|
||||
self.columns.hide(column.name)
|
||||
|
||||
# Rearrange the sequence to list selected columns first, followed by all remaining columns
|
||||
# TODO: There's probably a more clever way to accomplish this
|
||||
self.sequence = [
|
||||
*[c for c in selected_columns if c in self.columns.names()],
|
||||
*[c for c in self.columns.names() if c not in selected_columns]
|
||||
]
|
||||
# Rearrange the sequence to list selected columns first, followed by all remaining columns
|
||||
# TODO: There's probably a more clever way to accomplish this
|
||||
self.sequence = [
|
||||
*[c for c in selected_columns if c in self.columns.names()],
|
||||
*[c for c in self.columns.names() if c not in selected_columns]
|
||||
]
|
||||
|
||||
# PK column should always come first
|
||||
if 'pk' in self.sequence:
|
||||
self.sequence.remove('pk')
|
||||
self.sequence.insert(0, 'pk')
|
||||
# PK column should always come first
|
||||
if 'pk' in self.sequence:
|
||||
self.sequence.remove('pk')
|
||||
self.sequence.insert(0, 'pk')
|
||||
|
||||
# Actions column should always come last
|
||||
if 'actions' in self.sequence:
|
||||
self.sequence.remove('actions')
|
||||
self.sequence.append('actions')
|
||||
# Actions column should always come last
|
||||
if 'actions' in self.sequence:
|
||||
self.sequence.remove('actions')
|
||||
self.sequence.append('actions')
|
||||
|
||||
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
|
||||
if isinstance(self.data, TableQuerysetData):
|
||||
@@ -127,6 +124,30 @@ class BaseTable(tables.Table):
|
||||
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
|
||||
return self._objects_count
|
||||
|
||||
def configure(self, request):
|
||||
"""
|
||||
Configure the table for a specific request context. This performs pagination and records
|
||||
the user's preferred ordering logic.
|
||||
"""
|
||||
# Save ordering preference
|
||||
if request.user.is_authenticated:
|
||||
table_name = self.__class__.__name__
|
||||
if self.prefixed_order_by_field in request.GET:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
ordering = request.GET.getlist(self.prefixed_order_by_field)
|
||||
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
|
||||
# If no ordering has been specified, set the preferred ordering (if any).
|
||||
self.order_by = ordering
|
||||
|
||||
# Paginate the table results
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
tables.RequestConfig(request, paginate).configure(self)
|
||||
|
||||
|
||||
class NetBoxTable(BaseTable):
|
||||
"""
|
||||
@@ -167,27 +188,3 @@ class NetBoxTable(BaseTable):
|
||||
])
|
||||
|
||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
||||
|
||||
def configure(self, request):
|
||||
"""
|
||||
Configure the table for a specific request context. This performs pagination and records
|
||||
the user's preferred ordering logic.
|
||||
"""
|
||||
# Save ordering preference
|
||||
if request.user.is_authenticated:
|
||||
table_name = self.__class__.__name__
|
||||
if self.prefixed_order_by_field in request.GET:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
ordering = request.GET.getlist(self.prefixed_order_by_field)
|
||||
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
|
||||
# If no ordering has been specified, set the preferred ordering (if any).
|
||||
self.order_by = ordering
|
||||
|
||||
# Paginate the table results
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
tables.RequestConfig(request, paginate).configure(self)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
@@ -42,27 +43,34 @@ class ObjectListView(BaseMultiObjectView):
|
||||
Attributes:
|
||||
filterset: A django-filter FilterSet that is applied to the queryset
|
||||
filterset_form: The form class used to render filter options
|
||||
action_buttons: A list of buttons to include at the top of the page
|
||||
actions: Supported actions for the model. When adding custom actions, bulk action names must
|
||||
be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete
|
||||
action_perms: A dictionary mapping supported actions to a set of permissions required for each
|
||||
"""
|
||||
template_name = 'generic/object_list.html'
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
action_buttons = ('add', 'import', 'export')
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
})
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
|
||||
def get_table(self, request, permissions):
|
||||
def get_table(self, request, bulk_actions=True):
|
||||
"""
|
||||
Return the django-tables2 Table instance to be used for rendering the objects list.
|
||||
|
||||
Args:
|
||||
request: The current request
|
||||
permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating
|
||||
whether the user has each
|
||||
bulk_actions: Show checkboxes for object selection
|
||||
"""
|
||||
table = self.table(self.queryset, user=request.user)
|
||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||
if 'pk' in table.base_columns and bulk_actions:
|
||||
table.columns.show('pk')
|
||||
|
||||
return table
|
||||
@@ -135,17 +143,20 @@ class ObjectListView(BaseMultiObjectView):
|
||||
if self.filterset:
|
||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||
|
||||
# Compile a dictionary indicating which permissions are available to the current user for this model
|
||||
permissions = {}
|
||||
for action in ('add', 'change', 'delete', 'view'):
|
||||
perm_name = get_permission_for_model(model, action)
|
||||
permissions[action] = request.user.has_perm(perm_name)
|
||||
# Determine the available actions
|
||||
actions = []
|
||||
for action in self.actions:
|
||||
if request.user.has_perms([
|
||||
get_permission_for_model(model, name) for name in self.action_perms[action]
|
||||
]):
|
||||
actions.append(action)
|
||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||
|
||||
if 'export' in request.GET:
|
||||
|
||||
# Export the current table view
|
||||
if request.GET['export'] == 'table':
|
||||
table = self.get_table(request, permissions)
|
||||
table = self.get_table(request, has_bulk_actions)
|
||||
columns = [name for name, _ in table.selected_columns]
|
||||
return self.export_table(table, columns)
|
||||
|
||||
@@ -163,11 +174,11 @@ class ObjectListView(BaseMultiObjectView):
|
||||
|
||||
# Fall back to default table/YAML export
|
||||
else:
|
||||
table = self.get_table(request, permissions)
|
||||
table = self.get_table(request, has_bulk_actions)
|
||||
return self.export_table(table)
|
||||
|
||||
# Render the objects table
|
||||
table = self.get_table(request, permissions)
|
||||
table = self.get_table(request, has_bulk_actions)
|
||||
table.configure(request)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
@@ -179,8 +190,7 @@ class ObjectListView(BaseMultiObjectView):
|
||||
context = {
|
||||
'model': model,
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'action_buttons': self.action_buttons,
|
||||
'actions': actions,
|
||||
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
||||
**self.get_extra_context(request),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import normalize_querydict, prepare_cloned_fields
|
||||
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
|
||||
from utilities.views import GetReturnURLMixin
|
||||
from .base import BaseObjectView
|
||||
|
||||
@@ -453,7 +453,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
||||
|
||||
# If this is an HTMX request, return only the rendered deletion form as modal content
|
||||
if is_htmx(request):
|
||||
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
|
||||
viewname = get_viewname(self.queryset.model, action='delete')
|
||||
form_url = reverse(viewname, kwargs={'pk': obj.pk})
|
||||
return render(request, 'htmx/delete_form.html', {
|
||||
'object': obj,
|
||||
@@ -559,42 +559,19 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
logger = logging.getLogger('netbox.views.ComponentCreateView')
|
||||
form, model_form = self.initialize_forms(request)
|
||||
instance = self.alter_object(self.queryset.model, request)
|
||||
|
||||
self.validate_form(request, form)
|
||||
|
||||
if form.is_valid() and not form.errors:
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'object': instance,
|
||||
'replication_form': form,
|
||||
'form': model_form,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
# TODO: Refactor this method for clarity & better error reporting
|
||||
def validate_form(self, request, form):
|
||||
"""
|
||||
Validate form values and set errors on the form object as they are detected. If
|
||||
no errors are found, signal success messages.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.views.ComponentCreateView')
|
||||
if form.is_valid():
|
||||
new_components = []
|
||||
data = deepcopy(request.POST)
|
||||
names = form.cleaned_data['name_pattern']
|
||||
labels = form.cleaned_data.get('label_pattern')
|
||||
pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern'])
|
||||
|
||||
for i, name in enumerate(names):
|
||||
label = labels[i] if labels else None
|
||||
# Initialize the individual component form
|
||||
data['name'] = name
|
||||
data['label'] = label
|
||||
for i in range(pattern_count):
|
||||
for field_name in self.patterned_fields:
|
||||
if form.cleaned_data.get(f'{field_name}_pattern'):
|
||||
data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i]
|
||||
|
||||
if hasattr(form, 'get_iterative_data'):
|
||||
data.update(form.get_iterative_data(i))
|
||||
@@ -620,8 +597,12 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
messages.success(request, "Added {} {}".format(
|
||||
len(new_components), self.queryset.model._meta.verbose_name_plural
|
||||
))
|
||||
# Return the newly created objects so overridden post methods can use the data as needed.
|
||||
return new_objs
|
||||
|
||||
# Redirect user on success
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
except PermissionsViolation:
|
||||
msg = "Component creation failed due to object-level permissions violation"
|
||||
@@ -629,4 +610,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
return None
|
||||
return render(request, self.template_name, {
|
||||
'object': instance,
|
||||
'replication_form': form,
|
||||
'form': model_form,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,16 @@ type ShowHideMap = {
|
||||
*
|
||||
* @example vlangroup_edit
|
||||
*/
|
||||
[view: string]: {
|
||||
[view: string]: string;
|
||||
};
|
||||
|
||||
type ShowHideLayout = {
|
||||
/**
|
||||
* Name of layout config
|
||||
*
|
||||
* @example vlangroup
|
||||
*/
|
||||
[config: string]: {
|
||||
/**
|
||||
* Default layout.
|
||||
*/
|
||||
@@ -19,15 +28,15 @@ type ShowHideMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of scope names to arrays of object types whose fields should be hidden or shown when
|
||||
* Mapping of layout names to arrays of object types whose fields should be hidden or shown when
|
||||
* the scope type (key) is selected.
|
||||
*
|
||||
* For example, if `region` is the scope type, the fields with IDs listed in
|
||||
* showHideMap.region.hide should be hidden, and the fields with IDs listed in
|
||||
* showHideMap.region.show should be shown.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_edit: {
|
||||
const showHideLayout: ShowHideLayout = {
|
||||
vlangroup: {
|
||||
region: {
|
||||
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region'],
|
||||
@@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of view names to layout configurations
|
||||
*
|
||||
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_add: 'vlangroup',
|
||||
vlangroup_edit: 'vlangroup',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle visibility of a given element's parent.
|
||||
* @param query CSS Query.
|
||||
@@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
|
||||
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
|
||||
// Scope type's innerText looks something like `DCIM > region`.
|
||||
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
|
||||
const layoutConfig = showHideMap[view];
|
||||
|
||||
for (const [scope, fields] of Object.entries(showHideMap[view])) {
|
||||
for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
|
||||
// If the scope type ends with the specified scope, toggle its field visibility according to
|
||||
// the show/hide values.
|
||||
if (scopeType.endsWith(scope)) {
|
||||
@@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
|
||||
break;
|
||||
} else {
|
||||
// Otherwise, hide all fields.
|
||||
for (const field of showHideMap[view].default.hide) {
|
||||
for (const field of showHideLayout[layoutConfig].default.hide) {
|
||||
toggleParentVisibility(`#${field}`, 'hide');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ $theme-colors: (
|
||||
'danger': $danger,
|
||||
'light': $light,
|
||||
'dark': $dark,
|
||||
|
||||
// General-purpose palette
|
||||
'blue': $blue-300,
|
||||
'indigo': $indigo-300,
|
||||
@@ -37,7 +36,7 @@ $theme-colors: (
|
||||
'cyan': $cyan-300,
|
||||
'gray': $gray-300,
|
||||
'black': $black,
|
||||
'white': $white,
|
||||
'white': $white
|
||||
);
|
||||
|
||||
// Gradient
|
||||
@@ -146,7 +145,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
|
||||
$nav-pills-link-active-color: $component-active-color;
|
||||
$nav-pills-link-active-bg: $component-active-bg;
|
||||
|
||||
$navbar-light-color: $darkest;
|
||||
$navbar-light-color: $navbar-dark-color;
|
||||
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
|
||||
$navbar-light-toggler-border-color: $gray-700;
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
function checkSideNav() {
|
||||
// Check localStorage to see if the sidebar should be pinned.
|
||||
var sideNavRaw = localStorage.getItem('netbox-sidenav');
|
||||
// Determine if the device has a small screeen. This media query is equivalent to
|
||||
@@ -154,11 +154,15 @@
|
||||
// jumpy/glitchy behavior on page reloads.
|
||||
document.body.setAttribute('data-sidenav-pinned', '');
|
||||
document.body.setAttribute('data-sidenav-show', '');
|
||||
document.body.removeAttribute('data-sidenav-hidden');
|
||||
} else {
|
||||
document.body.removeAttribute('data-sidenav-pinned');
|
||||
document.body.setAttribute('data-sidenav-hidden', '');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
window.addEventListener('resize', function(){ checkSideNav() });
|
||||
checkSideNav();
|
||||
</script>
|
||||
|
||||
{# Page layout #}
|
||||
|
||||
@@ -116,52 +116,54 @@ Blocks:
|
||||
|
||||
{# Page footer #}
|
||||
<footer class="footer container-fluid">
|
||||
<div class="row align-items-center justify-content-between mx-0">
|
||||
{% block footer %}
|
||||
<div class="row align-items-center justify-content-between mx-0">
|
||||
|
||||
{# Docs & Community Links #}
|
||||
<div class="col-sm-12 col-md-auto fs-4 noprint">
|
||||
<nav class="nav justify-content-center justify-content-lg-start">
|
||||
{# Documentation #}
|
||||
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
|
||||
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
<div class="col-sm-12 col-md-auto fs-4 noprint">
|
||||
<nav class="nav justify-content-center justify-content-lg-start">
|
||||
{% block footer_links %}
|
||||
{# Documentation #}
|
||||
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
|
||||
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# REST API #}
|
||||
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
|
||||
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{# REST API #}
|
||||
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
|
||||
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# API docs #}
|
||||
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
|
||||
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{# API docs #}
|
||||
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
|
||||
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# GraphQL API #}
|
||||
{% if config.GRAPHQL_ENABLED %}
|
||||
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
|
||||
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{# GraphQL API #}
|
||||
{% if config.GRAPHQL_ENABLED %}
|
||||
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
|
||||
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# GitHub #}
|
||||
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
|
||||
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{# GitHub #}
|
||||
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
|
||||
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# NetDev Slack #}
|
||||
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
|
||||
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endblock footer_links %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
|
||||
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
|
||||
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
|
||||
</div>
|
||||
|
||||
{# NetDev Slack #}
|
||||
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
|
||||
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{# System Info #}
|
||||
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
|
||||
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
|
||||
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock footer %}
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,14 @@
|
||||
{% load static %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{{ object.circuit.provider }} {{ object.circuit }} - Side {{ form.term_side.value }}{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Circuit Termination</h5>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">Provider</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ object.circuit.provider }}" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">Circuit</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ object.circuit.cid }}" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">Termination</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ form.term_side.value }}" disabled />
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.provider %}
|
||||
{% render_field form.circuit %}
|
||||
{% render_field form.term_side %}
|
||||
{% render_field form.mark_connected %}
|
||||
{% with providernetwork_tab_active=form.initial.provider_network %}
|
||||
<div class="row mb-2">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<strong class="d-block d-md-inline mb-3 mb-md-0">Termination - {{ side }} Side</strong>
|
||||
<div class="float-md-end">
|
||||
{% if not termination and perms.circuits.add_circuittermination %}
|
||||
<a href="{% url 'circuits:circuittermination_add' circuit=object.pk %}?term_side={{ side }}" class="btn btn-sm btn-success lh-1">
|
||||
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
{% render_form replication_form %}
|
||||
{% block replication_fields %}
|
||||
{% render_form replication_form %}
|
||||
{% endblock replication_fields %}
|
||||
{{ block.super }}
|
||||
{% endblock form %}
|
||||
|
||||
34
netbox/templates/dcim/component_template_create.html
Normal file
34
netbox/templates/dcim/component_template_create.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-3">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li role="presentation" class="nav-item">
|
||||
<button role="tab" type="button" id="devicetype_tab" data-bs-toggle="tab" aria-controls="devicetype" data-bs-target="#devicetype" class="nav-link {% if not form.initial.module_type %}active{% endif %}">
|
||||
Device Type
|
||||
</button>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<button role="tab" type="button" id="moduletype_tab" data-bs-toggle="tab" aria-controls="moduletype" data-bs-target="#moduletype" class="nav-link {% if form.initial.module_type %}active{% endif %}">
|
||||
Module Type
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane {% if not form.initial.module_type %}active{% endif %}" id="devicetype" role="tabpanel">
|
||||
{% render_field replication_form.device_type %}
|
||||
</div>
|
||||
<div class="tab-pane {% if form.initial.module_type %}active{% endif %}" id="moduletype" role="tabpanel">
|
||||
{% render_field replication_form.module_type %}
|
||||
</div>
|
||||
{% block replication_fields %}
|
||||
{% render_field replication_form.name_pattern %}
|
||||
{% render_field replication_form.label_pattern %}
|
||||
{% endblock replication_fields %}
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock form %}
|
||||
@@ -73,4 +73,5 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
7
netbox/templates/dcim/frontporttemplate_create.html
Normal file
7
netbox/templates/dcim/frontporttemplate_create.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends 'dcim/component_template_create.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block replication_fields %}
|
||||
{{ block.super }}
|
||||
{% render_field replication_form.rear_port_set %}
|
||||
{% endblock replication_fields %}
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
{% extends 'dcim/component_create.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
{% render_form replication_form %}
|
||||
{% block replication_fields %}
|
||||
{{ block.super }}
|
||||
{% if object.component %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">
|
||||
@@ -14,5 +14,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
{% endblock replication_fields %}
|
||||
|
||||
17
netbox/templates/dcim/inventoryitemtemplate_create.html
Normal file
17
netbox/templates/dcim/inventoryitemtemplate_create.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'dcim/component_template_create.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block replication_fields %}
|
||||
{{ block.super }}
|
||||
{% if object.component %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">
|
||||
{{ object.component|meta:"verbose_name"|bettertitle }}
|
||||
</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ object.component }}" disabled />
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock replication_fields %}
|
||||
@@ -34,6 +34,12 @@
|
||||
<a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}">{{ devicetypes_table.rows|length }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Module types</th>
|
||||
<td>
|
||||
<a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.pk }}">{{ module_type_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Inventory Items</th>
|
||||
<td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user