mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-25 03:08:15 +01:00
Compare commits
316 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50148fab7 | ||
|
|
271c2ea3e3 | ||
|
|
20a6f6ac79 | ||
|
|
8924d5fa05 | ||
|
|
26637d934b | ||
|
|
dde4495e20 | ||
|
|
1278429518 | ||
|
|
421f5a03aa | ||
|
|
a433d5d59d | ||
|
|
a5820e27a6 | ||
|
|
d312fe7c2b | ||
|
|
124fc73386 | ||
|
|
c78e7c14d3 | ||
|
|
30a6dc2f64 | ||
|
|
6ceb78fd4c | ||
|
|
e09ab79a1a | ||
|
|
b6587c00ce | ||
|
|
ae46cd33b6 | ||
|
|
41efad4056 | ||
|
|
5f89226cd7 | ||
|
|
197dfca5b2 | ||
|
|
e6980626d8 | ||
|
|
22980cea7b | ||
|
|
f64987d0c4 | ||
|
|
0da04232f3 | ||
|
|
9a0bb14e76 | ||
|
|
900825a2af | ||
|
|
52de50aa64 | ||
|
|
1541060091 | ||
|
|
50bc0caccf | ||
|
|
8f5b14ec84 | ||
|
|
da37db1ea9 | ||
|
|
5abde866f1 | ||
|
|
32eed72d2b | ||
|
|
585b5a221d | ||
|
|
db52fe475a | ||
|
|
c5db99f383 | ||
|
|
fd6d3205d0 | ||
|
|
9548cf32ff | ||
|
|
bdbfff911b | ||
|
|
a143eca57d | ||
|
|
1add5accf2 | ||
|
|
faba6c9bdc | ||
|
|
4eb7cd06b4 | ||
|
|
342f1d31be | ||
|
|
b779bbfc9d | ||
|
|
ef6576bdd6 | ||
|
|
27dab262de | ||
|
|
412c1df15a | ||
|
|
73af3ba095 | ||
|
|
21b7564976 | ||
|
|
bf22b820bf | ||
|
|
8cd24b1a67 | ||
|
|
1fdc7a9163 | ||
|
|
6807db4967 | ||
|
|
b0ea416d6d | ||
|
|
c515218760 | ||
|
|
8053ea0a22 | ||
|
|
a5603c9953 | ||
|
|
bffe63a233 | ||
|
|
2cfbfe473e | ||
|
|
3c78c100b5 | ||
|
|
2451b0a5b1 | ||
|
|
85e9438ff7 | ||
|
|
81610ba86e | ||
|
|
6423b386d2 | ||
|
|
5c48d116eb | ||
|
|
90257e9dee | ||
|
|
3436905744 | ||
|
|
e3258bcf5a | ||
|
|
2b6e0405a5 | ||
|
|
7f752d9102 | ||
|
|
df430394b0 | ||
|
|
1ab51ca04e | ||
|
|
cb0386779c | ||
|
|
28de330b50 | ||
|
|
06cb7f35f1 | ||
|
|
796c5d785e | ||
|
|
c88db77814 | ||
|
|
6fe0f4cd7d | ||
|
|
3bf90c3c38 | ||
|
|
992f3535b7 | ||
|
|
06eacb5a5c | ||
|
|
c0152ce52f | ||
|
|
5a60224d77 | ||
|
|
c137fa2022 | ||
|
|
879d01a750 | ||
|
|
6db878743c | ||
|
|
08b90090f5 | ||
|
|
42466d5fc4 | ||
|
|
36d6dd1ca9 | ||
|
|
4863591bc8 | ||
|
|
c489501441 | ||
|
|
1a7438acfd | ||
|
|
b1de85a44f | ||
|
|
4913d7ee39 | ||
|
|
2503a3e3ca | ||
|
|
538984c6d2 | ||
|
|
90ee689d5a | ||
|
|
b343035060 | ||
|
|
6bbf168cec | ||
|
|
b5e4fdc3d8 | ||
|
|
90f91eeea4 | ||
|
|
ae0ae5fd4e | ||
|
|
5b7486cff8 | ||
|
|
14240318f1 | ||
|
|
18eb9ffae6 | ||
|
|
c0a62793c4 | ||
|
|
dd848d754f | ||
|
|
f058850598 | ||
|
|
0c7220016b | ||
|
|
8c19124717 | ||
|
|
46f4359e1f | ||
|
|
f80452c7d9 | ||
|
|
611f1b57dd | ||
|
|
d1b1a45725 | ||
|
|
6e38f7e532 | ||
|
|
2c1e681984 | ||
|
|
f11ad99983 | ||
|
|
e1ef911d40 | ||
|
|
a4ca585ef2 | ||
|
|
076461a1b6 | ||
|
|
0c7407ebb6 | ||
|
|
f13a3fa549 | ||
|
|
c0a65eb593 | ||
|
|
450a7730d3 | ||
|
|
41ee4b642f | ||
|
|
3ee3c52e14 | ||
|
|
e76a5bfd85 | ||
|
|
59c89a3b9d | ||
|
|
8fc605037a | ||
|
|
311ddf82c5 | ||
|
|
9d65486c64 | ||
|
|
ccce7751a0 | ||
|
|
7252f0b490 | ||
|
|
094d2e586a | ||
|
|
6c1507c88c | ||
|
|
60f48326e1 | ||
|
|
5b985a924b | ||
|
|
ee74989f74 | ||
|
|
e2fc7e8cd7 | ||
|
|
aff55881df | ||
|
|
4d066a075d | ||
|
|
201077b6f6 | ||
|
|
795134c084 | ||
|
|
4f689223b4 | ||
|
|
70ce7293ac | ||
|
|
94a0a3b568 | ||
|
|
69305f0509 | ||
|
|
24f48b11e6 | ||
|
|
ff3b48fa59 | ||
|
|
db3f478598 | ||
|
|
e20ac803f3 | ||
|
|
ea283365e7 | ||
|
|
8211830bd8 | ||
|
|
2a8e0f9404 | ||
|
|
c15cfc26f1 | ||
|
|
4f4e6938eb | ||
|
|
8545a547b9 | ||
|
|
3bb7184f28 | ||
|
|
dd71942a5e | ||
|
|
19fdd5e151 | ||
|
|
f537dc632e | ||
|
|
2221006970 | ||
|
|
5d29c5958b | ||
|
|
64dd46c7e4 | ||
|
|
8df382d976 | ||
|
|
31c58409e1 | ||
|
|
69eb6b11d0 | ||
|
|
1f2d4fd2b3 | ||
|
|
21468fff25 | ||
|
|
4711b4d529 | ||
|
|
29d4859e02 | ||
|
|
4b81d86311 | ||
|
|
38963e7960 | ||
|
|
1584d51433 | ||
|
|
98571c62a6 | ||
|
|
69f525bfd3 | ||
|
|
2b31154834 | ||
|
|
b0948ea018 | ||
|
|
a50e4e3380 | ||
|
|
5564664b13 | ||
|
|
1ae5a2c808 | ||
|
|
0181a25d70 | ||
|
|
60ba4a9830 | ||
|
|
3802a78c9d | ||
|
|
0ca6d73614 | ||
|
|
aa77f8f0d2 | ||
|
|
381796e708 | ||
|
|
62fc7717c8 | ||
|
|
e19451bb4f | ||
|
|
85f588e8c9 | ||
|
|
ea644868a6 | ||
|
|
d08accaaf1 | ||
|
|
f49272cacb | ||
|
|
be8fef0228 | ||
|
|
b584f09223 | ||
|
|
d2968c95df | ||
|
|
7421e5f7d7 | ||
|
|
0b2a43cfcc | ||
|
|
50309d3ab3 | ||
|
|
dd0b16bff5 | ||
|
|
d5443adc74 | ||
|
|
9152ba72f1 | ||
|
|
076ca46ab4 | ||
|
|
02519b270e | ||
|
|
5aa7dedccb | ||
|
|
6383dfa854 | ||
|
|
5a4fb0323b | ||
|
|
e84a282aa6 | ||
|
|
f732493473 | ||
|
|
f66a265fcf | ||
|
|
f1472d218e | ||
|
|
d65c05aacd | ||
|
|
2b28ffa2f4 | ||
|
|
10ec31df3e | ||
|
|
184b1055dc | ||
|
|
eaec25e6c2 | ||
|
|
b63e29610e | ||
|
|
b0db5a8b0a | ||
|
|
d3e2241ff7 | ||
|
|
e90b9f6c19 | ||
|
|
4c1199e009 | ||
|
|
65471068b6 | ||
|
|
c6467a824b | ||
|
|
b1d1f3c6b2 | ||
|
|
574c2e2770 | ||
|
|
aec2d233c9 | ||
|
|
39418f2bbe | ||
|
|
ccda73494f | ||
|
|
443b4ccc57 | ||
|
|
511aedd5db | ||
|
|
2524290099 | ||
|
|
01e8017265 | ||
|
|
8338fc405f | ||
|
|
0a22b3990f | ||
|
|
662cafe416 | ||
|
|
ea961ba8f2 | ||
|
|
8c8774cd2f | ||
|
|
2fe02ddb1f | ||
|
|
e11e8a5d64 | ||
|
|
79bebf7c9b | ||
|
|
8d3b660ce0 | ||
|
|
9de53fe070 | ||
|
|
ecb9fc65b7 | ||
|
|
7b25d0379f | ||
|
|
05d4176d34 | ||
|
|
7b0dff88ae | ||
|
|
1c7604e0fe | ||
|
|
e18dc43aae | ||
|
|
caaad684a4 | ||
|
|
cdd51aee75 | ||
|
|
51851f6c99 | ||
|
|
ab98aa489c | ||
|
|
5829985ca8 | ||
|
|
2fa8e27f05 | ||
|
|
68f92dfd5d | ||
|
|
67aeb380e7 | ||
|
|
f7d91b7139 | ||
|
|
b6e157f393 | ||
|
|
2319fce092 | ||
|
|
a5f1707662 | ||
|
|
6cda55da06 | ||
|
|
c3f2fee633 | ||
|
|
1f575a2a47 | ||
|
|
13c4d13157 | ||
|
|
43fadab3bb | ||
|
|
82a0240d2e | ||
|
|
f2aa35d3d2 | ||
|
|
9c9fcaf42f | ||
|
|
146a51ceba | ||
|
|
b0350e9e96 | ||
|
|
35e346c4b9 | ||
|
|
1987647cc3 | ||
|
|
542534aeba | ||
|
|
908a2824ba | ||
|
|
cab9733b60 | ||
|
|
99e0dcec76 | ||
|
|
9dafb36c88 | ||
|
|
3d7d19b608 | ||
|
|
d650d10cb2 | ||
|
|
7fe45018e9 | ||
|
|
4c4cab87fb | ||
|
|
94c7f64baf | ||
|
|
f369b5f588 | ||
|
|
37065b7c50 | ||
|
|
0a7372460f | ||
|
|
063abc8ef7 | ||
|
|
fb4511d099 | ||
|
|
275560698f | ||
|
|
d4b6fe14c3 | ||
|
|
f1350a1022 | ||
|
|
344fb638fd | ||
|
|
373cc74a33 | ||
|
|
8e95ac42c2 | ||
|
|
ceb941df81 | ||
|
|
d275538116 | ||
|
|
fa38cdbc0d | ||
|
|
7569544b7b | ||
|
|
853a52f3ca | ||
|
|
39a0b15df4 | ||
|
|
a0db10838b | ||
|
|
f2f10dff92 | ||
|
|
7ba45b2887 | ||
|
|
c91eb8f406 | ||
|
|
57a78b3cad | ||
|
|
b755c7dab3 | ||
|
|
9ffd791ae4 | ||
|
|
8af12b22bb | ||
|
|
17ba0a97d5 | ||
|
|
4ae2b4e0b9 | ||
|
|
872691a138 | ||
|
|
3a54ecb522 | ||
|
|
42b590af77 | ||
|
|
2ec64a2ea2 | ||
|
|
038d7e0fa6 |
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.2
|
||||
placeholder: v3.1.10
|
||||
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.2
|
||||
placeholder: v3.1.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -38,14 +38,26 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Yarn Package Manager
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
cache-dependency-path: netbox/project-static/yarn.lock
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: yarn --cwd netbox/project-static
|
||||
|
||||
- name: Install dependencies & set up configuration
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pycodestyle coverage
|
||||
pip install pycodestyle coverage tblib
|
||||
ln -s configuration.testing.py netbox/netbox/configuration.py
|
||||
yarn --cwd netbox/project-static
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
@@ -63,7 +75,7 @@ jobs:
|
||||
run: scripts/verify-bundles.sh
|
||||
|
||||
- name: Run tests
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
|
||||
|
||||
- name: Show coverage report
|
||||
run: coverage report --skip-covered --omit *migrations*
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ yarn-error.log*
|
||||
!/netbox/project-static/docs/.info
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -16,13 +16,6 @@ categories for discussions:
|
||||
feature request
|
||||
* **Q&A** - Request help with installing or using NetBox
|
||||
|
||||
### Mailing List
|
||||
|
||||
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
|
||||
for general discussion, however we're encouraging people to use GitHub
|
||||
discussions where possible, as it's much easier for newcomers to review past
|
||||
discussions.
|
||||
|
||||
### Slack
|
||||
|
||||
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
||||
|
||||
40
README.md
40
README.md
@@ -2,14 +2,51 @@
|
||||
<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
|
||||
network automation. Initially conceived by the network engineering team at
|
||||
network automation, used by thousands of organizations around the world.
|
||||
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. It is intended to
|
||||
function as a domain-specific source of truth for network operations.
|
||||
|
||||
Myriad infrastructure components can be modeled in NetBox, including:
|
||||
|
||||
* Hierarchical regions, site groups, sites, and locations
|
||||
* Racks, devices, and device components
|
||||
* Cables and wireless connections
|
||||
* Power distribution
|
||||
* Data circuits and providers
|
||||
* Virtual machines and clusters
|
||||
* IP prefixes, ranges, and addresses
|
||||
* VRFs and route targets
|
||||
* FHRP groups (VRRP, HSRP, etc.)
|
||||
* AS numbers
|
||||
* VLANs and scoped VLAN groups
|
||||
* Organizational tenants and contacts
|
||||
|
||||
In addition to its extensive built-in models and functionality, NetBox can be
|
||||
customized and extended through the use of:
|
||||
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Configuration contexts
|
||||
* Custom model validation rules
|
||||
* Reports
|
||||
* Custom scripts
|
||||
* Export templates
|
||||
* Conditional webhooks
|
||||
* Plugins
|
||||
* Single sign-on (SSO) authentication
|
||||
* NAPALM integration
|
||||
* Detailed change logging
|
||||
|
||||
NetBox also features a complete REST API as well as a GraphQL API for easily
|
||||
integrating with other tools and systems.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
|
||||
@@ -33,7 +70,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
|
||||
|
||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
||||
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
|
||||
|
||||
### Installation
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ markdown-include
|
||||
mkdocs-material
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/drkjam/netaddr
|
||||
# https://github.com/netaddr/netaddr
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
@@ -98,13 +98,9 @@ psycopg2-binary
|
||||
# https://github.com/yaml/pyyaml
|
||||
PyYAML
|
||||
|
||||
# In-memory key/value store used for caching and queuing
|
||||
# https://github.com/andymccurdy/redis-py
|
||||
redis
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
social-auth-core[all]
|
||||
social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django
|
||||
|
||||
0
contrib/netbox-housekeeping.sh
Normal file → Executable file
0
contrib/netbox-housekeeping.sh
Normal file → Executable file
@@ -1,5 +1,22 @@
|
||||
{!models/extras/webhook.md!}
|
||||
|
||||
## Conditional Webhooks
|
||||
|
||||
A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
|
||||
|
||||
```json
|
||||
{
|
||||
"and": [
|
||||
{
|
||||
"attr": "status.value",
|
||||
"value": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
|
||||
|
||||
## Webhook Processing
|
||||
|
||||
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
|
||||
|
||||
* Clearing expired authentication sessions from the database
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
|
||||
|
||||
Default: `{}` (Empty dictionary)
|
||||
|
||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
|
||||
|
||||
Default: `False`
|
||||
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
---
|
||||
|
||||
{!models/ipam/fhrpgroup.md!}
|
||||
{!models/ipam/fhrpgroupassignment.md!}
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ This is the human-friendly names of your script. If omitted, the class name will
|
||||
|
||||
A human-friendly description of what your script does.
|
||||
|
||||
### `field_order`
|
||||
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
|
||||
|
||||
### `commit_default`
|
||||
|
||||
The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default.
|
||||
|
||||
@@ -50,7 +50,7 @@ The `fail()` method may optionally specify a field with which to associate the s
|
||||
|
||||
## Assigning Custom Validators
|
||||
|
||||
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
|
||||
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/dynamic-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
|
||||
|
||||
1. Plain JSON mapping (no custom logic)
|
||||
2. Dotted path to a custom validator class
|
||||
|
||||
@@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following
|
||||
|
||||
Add the relevant URL path for each view created in the previous step to `urls.py`.
|
||||
|
||||
## 6. Create the FilterSet
|
||||
## 6. Add relevant forms
|
||||
|
||||
Depending on the type of model being added, you may need to define several types of form classes. These include:
|
||||
|
||||
* A base model form (for creating/editing individual objects)
|
||||
* A bulk edit form
|
||||
* A bulk import form (for CSV-based import)
|
||||
* A filterset form (for filtering the object list view)
|
||||
|
||||
## 7. Create the FilterSet
|
||||
|
||||
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
|
||||
|
||||
## 7. Create the table class
|
||||
## 8. Create the table class
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 8. Create the object template
|
||||
## 9. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 9. Add the model to the navigation menu
|
||||
## 10. Add the model to the navigation menu
|
||||
|
||||
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
|
||||
|
||||
## 10. REST API components
|
||||
## 11. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
@@ -62,13 +71,13 @@ Create the following for each model:
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 11. GraphQL API components
|
||||
## 12. GraphQL API components
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 12. Add tests
|
||||
## 13. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
@@ -76,7 +85,7 @@ Add tests for the following:
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 13. Documentation
|
||||
## 14. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
|
||||
@@ -114,24 +114,32 @@ This ensures that your development environment is now complete and operational.
|
||||
!!! info "IDE Integration"
|
||||
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
|
||||
|
||||
## Populating Demo Data
|
||||
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
|
||||
|
||||
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
When running tests, it's advised to use the special testing configuration file that ships with NetBox. This ensures that tests are run with the same configuration parameters consistently. To override your local configuration when running tests, set the `NETBOX_CONFIGURATION` environment variable to `netbox.configuration_testing`.
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test
|
||||
$ NETBOX_CONFIGURATION=netbox.configuration_testing 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.)
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test --keepdb
|
||||
$ 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:
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test dcim.tests.test_views ipam.tests.test_views
|
||||
$ python manage.py test dcim.tests.test_views ipam.tests.test_views
|
||||
```
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
@@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
There are several official forums for communication among the developers and community members:
|
||||
|
||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
|
||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
|
||||
|
||||
## Governance
|
||||
|
||||
|
||||
@@ -67,4 +67,4 @@ Authorization: Token $TOKEN
|
||||
|
||||
## Disabling the GraphQL API
|
||||
|
||||
If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/optional-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.
|
||||
If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/dynamic-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.
|
||||
|
||||
@@ -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:
|
||||
@@ -50,7 +52,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL 10+ |
|
||||
| Task queuing | Redis/django-rq |
|
||||
| Live device access | NAPALM |
|
||||
| Live device access | NAPALM (optional) |
|
||||
|
||||
## Supported Python Versions
|
||||
|
||||
@@ -58,4 +60,6 @@ NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Py
|
||||
|
||||
## Getting Started
|
||||
|
||||
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
|
||||
Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
|
||||
|
||||
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.
|
||||
|
||||
@@ -152,7 +152,7 @@ LOGGING = {
|
||||
'netbox_auth_log': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': '/opt/netbox/logs/django-ldap-debug.log',
|
||||
'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
|
||||
'maxBytes': 1024 * 500,
|
||||
'backupCount': 5,
|
||||
},
|
||||
|
||||
@@ -11,10 +11,6 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|
||||
@@ -55,3 +55,7 @@ The link will only appear when viewing a device with a manufacturer name of "Cis
|
||||
## Link Groups
|
||||
|
||||
Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group.
|
||||
|
||||
## Table Columns
|
||||
|
||||
Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL.
|
||||
|
||||
@@ -81,16 +81,3 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Webhooks
|
||||
|
||||
A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
|
||||
|
||||
```json
|
||||
{
|
||||
"attr": "status",
|
||||
"value": "active"
|
||||
}
|
||||
```
|
||||
|
||||
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
|
||||
|
||||
@@ -8,9 +8,3 @@ A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to p
|
||||
* Gateway Load Balancing Protocol (GLBP)
|
||||
|
||||
NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
|
||||
|
||||
## FHRP Group Assignments
|
||||
|
||||
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
|
||||
|
||||
Interfaces are assigned to FHRP groups under the interface detail view.
|
||||
|
||||
5
docs/models/ipam/fhrpgroupassignment.md
Normal file
5
docs/models/ipam/fhrpgroupassignment.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# FHRP Group Assignments
|
||||
|
||||
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
|
||||
|
||||
Interfaces are assigned to FHRP groups under the interface detail view.
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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:
|
||||
|
||||
@@ -81,13 +81,16 @@ The following condition will evaluate as true:
|
||||
|
||||
```json
|
||||
{
|
||||
"attr": "status",
|
||||
"attr": "status.value",
|
||||
"value": ["planned", "staging"],
|
||||
"op": "in",
|
||||
"negate": true
|
||||
}
|
||||
```
|
||||
|
||||
!!! note "Evaluating static choice fields"
|
||||
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match.
|
||||
|
||||
## Condition Sets
|
||||
|
||||
Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.
|
||||
@@ -102,7 +105,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
{
|
||||
"and": [
|
||||
{
|
||||
"attr": "status",
|
||||
"attr": "status.value",
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Release Notes
|
||||
|
||||
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
|
||||
NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
|
||||
|
||||
* **Major** - Introduces or removes an entire API or other core functionality
|
||||
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
|
||||
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
|
||||
|
||||
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 3.1](./version-3.1.md) (December 2021)
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ More information about IP ranges is available [in the documentation](../models/i
|
||||
|
||||
#### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963))
|
||||
|
||||
This release introduces the [`CUSTOM_VALIDATORS`](../configuration/optional-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description:
|
||||
This release introduces the [`CUSTOM_VALIDATORS`](../configuration/dynamic-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description:
|
||||
|
||||
```python
|
||||
from extras.validators import CustomValidator
|
||||
|
||||
@@ -1,5 +1,203 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## v3.1.10 (2022-03-25)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8232](https://github.com/netbox-community/netbox/issues/8232) - Use a different color for 100% utilization bars
|
||||
* [#8457](https://github.com/netbox-community/netbox/issues/8457) - Enable adding non-racked devices from site & location views
|
||||
* [#8553](https://github.com/netbox-community/netbox/issues/8553) - Add missing object types to global search form
|
||||
* [#8575](https://github.com/netbox-community/netbox/issues/8575) - Add rack columns to cables list
|
||||
* [#8645](https://github.com/netbox-community/netbox/issues/8645) - Enable filtering objects by assigned contacts & contact roles
|
||||
* [#8926](https://github.com/netbox-community/netbox/issues/8926) - Add device type, role columns to device bay table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8696](https://github.com/netbox-community/netbox/issues/8696) - Fix help link under FHRP group assigment creation view
|
||||
* [#8813](https://github.com/netbox-community/netbox/issues/8813) - Retain global search bar query after submitting
|
||||
* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode
|
||||
* [#8850](https://github.com/netbox-community/netbox/issues/8850) - Show airflow field on device REST API serializer when config context data is included
|
||||
* [#8905](https://github.com/netbox-community/netbox/issues/8905) - Disable ordering by assigned tags to prevent erroneous results
|
||||
* [#8919](https://github.com/netbox-community/netbox/issues/8919) - Fix filtering of VLAN groups by site under prefix edit form
|
||||
* [#8924](https://github.com/netbox-community/netbox/issues/8924) - Improve load time of custom script list
|
||||
* [#8932](https://github.com/netbox-community/netbox/issues/8932) - Fix error when setting null value for interface `rf_role` via REST API
|
||||
* [#8935](https://github.com/netbox-community/netbox/issues/8935) - Correct ordering of next/previous racks to use naturalized names
|
||||
* [#8947](https://github.com/netbox-community/netbox/issues/8947) - Retain filter parameters when handling an export template exception
|
||||
* [#8951](https://github.com/netbox-community/netbox/issues/8951) - Allow changing device type & platform to different manufacturer simultaneously
|
||||
* [#8952](https://github.com/netbox-community/netbox/issues/8952) - Device images in rear rack elevations should be hyperlinked
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## v3.1.8 (2022-02-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
|
||||
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
|
||||
* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
|
||||
* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
|
||||
* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
|
||||
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
|
||||
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
|
||||
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
|
||||
* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
|
||||
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
|
||||
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
|
||||
* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
|
||||
* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
|
||||
|
||||
---
|
||||
|
||||
## v3.1.7 (2022-02-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7504](https://github.com/netbox-community/netbox/issues/7504) - Include IP range data under IPAM role views
|
||||
* [#8275](https://github.com/netbox-community/netbox/issues/8275) - Introduce alternative ASDOT-formatted column for ASNs
|
||||
* [#8367](https://github.com/netbox-community/netbox/issues/8367) - Add ASNs to global search function
|
||||
* [#8368](https://github.com/netbox-community/netbox/issues/8368) - Enable controlling the order of custom script form fields with `field_order`
|
||||
* [#8381](https://github.com/netbox-community/netbox/issues/8381) - Add contacts to global search function
|
||||
* [#8462](https://github.com/netbox-community/netbox/issues/8462) - Linkify manufacturer column in device type table
|
||||
* [#8476](https://github.com/netbox-community/netbox/issues/8476) - Bring the ASN Web UI up to the standard set by other objects
|
||||
* [#8494](https://github.com/netbox-community/netbox/issues/8494) - Include locations count under tenant view
|
||||
* [#8517](https://github.com/netbox-community/netbox/issues/8517) - Render boolean custom fields as icons in object tables
|
||||
* [#8530](https://github.com/netbox-community/netbox/issues/8530) - Indicate CSV or YAML as format for "all data" export
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8315](https://github.com/netbox-community/netbox/issues/8315) - Fix display of NAT link for primary IPv4 address under device view
|
||||
* [#8377](https://github.com/netbox-community/netbox/issues/8377) - Fix calculation of absolute cable lengths when specified in fractional units
|
||||
* [#8425](https://github.com/netbox-community/netbox/issues/8425) - Fix exception when viewing change list/records with removed plugins
|
||||
* [#8456](https://github.com/netbox-community/netbox/issues/8456) - Fix redundant display of VRF RD in prefix view
|
||||
* [#8465](https://github.com/netbox-community/netbox/issues/8465) - Accept empty string values for Interface `rf_channel` in REST API
|
||||
* [#8498](https://github.com/netbox-community/netbox/issues/8498) - Fix display of selected content type filters in object list views
|
||||
* [#8499](https://github.com/netbox-community/netbox/issues/8499) - Content types REST API endpoint should not require model permission
|
||||
* [#8512](https://github.com/netbox-community/netbox/issues/8512) - Correct file permissions to allow execution of housekeeping script
|
||||
* [#8527](https://github.com/netbox-community/netbox/issues/8527) - Fix display of changelog retention period
|
||||
|
||||
---
|
||||
|
||||
## v3.1.6 (2022-01-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
|
||||
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
|
||||
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
|
||||
* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation
|
||||
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
|
||||
* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations
|
||||
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
|
||||
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
|
||||
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
|
||||
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
|
||||
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
|
||||
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
|
||||
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
|
||||
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
|
||||
* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers
|
||||
* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form
|
||||
* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms
|
||||
|
||||
---
|
||||
|
||||
## v3.1.5 (2022-01-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
|
||||
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
|
||||
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
|
||||
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
|
||||
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
|
||||
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
|
||||
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
|
||||
|
||||
---
|
||||
|
||||
## v3.1.4 (2022-01-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view
|
||||
* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI
|
||||
* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable
|
||||
* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables
|
||||
* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces
|
||||
* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI
|
||||
* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values
|
||||
|
||||
---
|
||||
|
||||
## v3.1.3 (2021-12-29)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6782](https://github.com/netbox-community/netbox/issues/6782) - Enable the inclusion of custom links in tables
|
||||
* [#7600](https://github.com/netbox-community/netbox/issues/7600) - Include count of available IPs on prefix view
|
||||
* [#8034](https://github.com/netbox-community/netbox/issues/8034) - Enable specifying custom field validators during CSV import
|
||||
* [#8100](https://github.com/netbox-community/netbox/issues/8100) - Add "other" choice for FHRP group protocol
|
||||
* [#8175](https://github.com/netbox-community/netbox/issues/8175) - Display parent object when attaching an image
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7246](https://github.com/netbox-community/netbox/issues/7246) - Don't attempt to URL-decode NAPALM response payloads
|
||||
* [#7290](https://github.com/netbox-community/netbox/issues/7290) - Defer loading API-backed form fields
|
||||
* [#7887](https://github.com/netbox-community/netbox/issues/7887) - Forward `HTTP_X_FORWARDED_FOR` to custom scripts
|
||||
* [#7962](https://github.com/netbox-community/netbox/issues/7962) - Fix user menu under report/script result view
|
||||
* [#7972](https://github.com/netbox-community/netbox/issues/7972) - Standardize name of `RemoteUserBackend` logger
|
||||
* [#8097](https://github.com/netbox-community/netbox/issues/8097) - Fix styling of Markdown tables
|
||||
* [#8127](https://github.com/netbox-community/netbox/issues/8127) - Fix disassociation of interface under IP address edit view
|
||||
* [#8131](https://github.com/netbox-community/netbox/issues/8131) - Restore annotation of available IPs under prefix IPs view
|
||||
* [#8134](https://github.com/netbox-community/netbox/issues/8134) - Fix bulk editing of objects within dynamic tables
|
||||
* [#8139](https://github.com/netbox-community/netbox/issues/8139) - Fix rendering of table configuration form under VM interfaces view
|
||||
* [#8140](https://github.com/netbox-community/netbox/issues/8140) - Restore missing fields on wireless LAN & link REST API serializers
|
||||
|
||||
---
|
||||
|
||||
## v3.1.2 (2021-12-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -42,7 +42,7 @@ $ curl -X POST \
|
||||
https://netbox/api/users/tokens/provision/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password: "I<3C3H8",
|
||||
"password": "I<3C3H8",
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -34,7 +36,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:
|
||||
|
||||
@@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
|
||||
fields = [
|
||||
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
||||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
|
||||
'_occupied',
|
||||
'_occupied', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.filters import TagFilter
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
from .models import *
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterSet(PrimaryModelFilterSet):
|
||||
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -98,7 +98,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ProviderNetwork
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -115,10 +115,10 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -193,7 +193,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, 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():
|
||||
@@ -234,7 +234,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():
|
||||
|
||||
@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.forms import CustomFieldModelFilterForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
|
||||
|
||||
__all__ = (
|
||||
@@ -16,24 +16,23 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterForm(CustomFieldModelFilterForm):
|
||||
class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Provider
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['asn'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -42,8 +41,7 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
required=False,
|
||||
@@ -61,8 +59,7 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label=_('Provider'),
|
||||
fetch_trigger='open'
|
||||
label=_('Provider')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -72,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Circuit
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -80,18 +77,17 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
['type_id', 'status', 'commit_rate'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
label=_('Type'),
|
||||
fetch_trigger='open'
|
||||
label=_('Type')
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label=_('Provider'),
|
||||
fetch_trigger='open'
|
||||
label=_('Provider')
|
||||
)
|
||||
provider_network_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
@@ -99,8 +95,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'provider_id': '$provider_id'
|
||||
},
|
||||
label=_('Provider network'),
|
||||
fetch_trigger='open'
|
||||
label=_('Provider network')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=CircuitStatusChoices,
|
||||
@@ -110,14 +105,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -126,8 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
|
||||
@@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
#
|
||||
# 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(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
@@ -37,6 +58,9 @@ class ProviderTable(BaseTable):
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
@@ -45,7 +69,7 @@ class ProviderTable(BaseTable):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags',
|
||||
'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@@ -69,7 +93,7 @@ class ProviderNetworkTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'provider', 'description')
|
||||
|
||||
|
||||
@@ -92,7 +116,7 @@ class CircuitTypeTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CircuitType
|
||||
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
|
||||
|
||||
|
||||
@@ -119,7 +143,11 @@ class CircuitTable(BaseTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
)
|
||||
@@ -128,7 +156,7 @@ class CircuitTable(BaseTable):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'tags',
|
||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', '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]}
|
||||
|
||||
@@ -219,7 +219,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
|
||||
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
@@ -497,9 +497,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
|
||||
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
@@ -619,9 +619,9 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -762,7 +762,7 @@ class CableSerializer(PrimaryModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
|
||||
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags', 'custom_fields',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def _get_termination(self, obj, side):
|
||||
@@ -856,7 +856,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -875,7 +878,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
|
||||
@@ -15,14 +15,14 @@ from circuits.models import Circuit
|
||||
from dcim import filtersets
|
||||
from dcim.models import *
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from ipam.models import Prefix, VLAN, ASN
|
||||
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.config import get_config
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related, decode_dict
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -501,7 +501,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||
continue
|
||||
try:
|
||||
response[method] = decode_dict(getattr(d, method)())
|
||||
response[method] = getattr(d, method)()
|
||||
except NotImplementedError:
|
||||
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
||||
except Exception as e:
|
||||
|
||||
@@ -816,6 +816,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
|
||||
TYPE_FLEXSTACK = 'cisco-flexstack'
|
||||
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
|
||||
TYPE_STACKWISE80 = 'cisco-stackwise-80'
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@@ -950,6 +954,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
|
||||
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
|
||||
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
|
||||
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
@@ -1005,13 +1013,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'
|
||||
@@ -1051,12 +1065,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'),
|
||||
|
||||
@@ -7,8 +7,8 @@ from ipam.models import ASN
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
|
||||
)
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from tenancy.models import *
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
@@ -62,7 +62,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterSet(OrganizationalModelFilterSet):
|
||||
class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Parent region (ID)',
|
||||
@@ -80,7 +80,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteGroupFilterSet(OrganizationalModelFilterSet):
|
||||
class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Parent site group (ID)',
|
||||
@@ -98,7 +98,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -142,7 +142,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email',
|
||||
'contact_email', 'description'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
@@ -237,10 +237,10 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -385,7 +385,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'created']
|
||||
fields = ['id', 'created', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet):
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
@@ -586,7 +586,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):
|
||||
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1289,7 +1289,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet):
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.cleaned_data['mode']:
|
||||
if self.cleaned_data['untagged_vlan']:
|
||||
raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
|
||||
elif self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError({
|
||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||
})
|
||||
|
||||
@@ -605,6 +605,19 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
|
||||
'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:
|
||||
|
||||
@@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
@@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
|
||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags',
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
|
||||
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect,
|
||||
@@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_circuit = DynamicModelChoiceField(
|
||||
@@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags',
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
|
||||
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
@@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
@@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
|
||||
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
|
||||
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
|
||||
'color', 'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
|
||||
@@ -5,9 +5,10 @@ from django.utils.translation import gettext as _
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from tenancy.models import *
|
||||
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
|
||||
from ipam.models import ASN
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import (
|
||||
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
|
||||
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
@@ -57,14 +58,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -73,8 +72,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@@ -82,14 +80,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
label=_('Location')
|
||||
)
|
||||
virtual_chassis_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Chassis'),
|
||||
fetch_trigger='open'
|
||||
label=_('Virtual Chassis')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -99,40 +95,48 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id'
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterForm(CustomFieldModelFilterForm):
|
||||
class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Region
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['parent_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Parent region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Parent region')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteGroupFilterForm(CustomFieldModelFilterForm):
|
||||
class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = SiteGroup
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['parent_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Parent group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Parent group')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Site
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['status', 'region_id', 'group_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['asn_id']
|
||||
['asn_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -142,42 +146,38 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
asn_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
required=False,
|
||||
label=_('ASNs'),
|
||||
fetch_trigger='open'
|
||||
label=_('ASNs')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Location
|
||||
field_groups = [
|
||||
['q'],
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id', 'parent_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -186,8 +186,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@@ -196,8 +195,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_('Parent'),
|
||||
fetch_trigger='open'
|
||||
label=_('Parent')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -207,7 +205,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Rack
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -215,12 +213,12 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
['status', 'role_id'],
|
||||
['type', 'width', 'serial', 'asset_tag'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -228,8 +226,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@@ -238,8 +235,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
label=_('Location')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=RackStatusChoices,
|
||||
@@ -260,8 +256,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=RackRole.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
serial = forms.CharField(
|
||||
required=False
|
||||
@@ -280,8 +275,7 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -296,8 +290,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -305,15 +298,13 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.prefetch_related('site'),
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
null_option='None',
|
||||
fetch_trigger='open'
|
||||
null_option='None'
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
@@ -321,14 +312,17 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ManufacturerFilterForm(CustomFieldModelFilterForm):
|
||||
class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Manufacturer
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -342,8 +336,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
subdevice_role = forms.MultipleChoiceField(
|
||||
choices=add_blank_choice(SubdeviceRoleChoices),
|
||||
@@ -410,13 +403,12 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Device
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -428,18 +420,17 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
|
||||
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
|
||||
],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -448,8 +439,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@@ -458,8 +448,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
label=_('Location')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -469,20 +458,17 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
label=_('Rack'),
|
||||
fetch_trigger='open'
|
||||
label=_('Rack')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -490,15 +476,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
query_params={
|
||||
'manufacturer_id': '$manufacturer_id'
|
||||
},
|
||||
label=_('Model'),
|
||||
fetch_trigger='open'
|
||||
label=_('Model')
|
||||
)
|
||||
platform_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Platform'),
|
||||
fetch_trigger='open'
|
||||
label=_('Platform')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=DeviceStatusChoices,
|
||||
@@ -589,14 +573,12 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -605,8 +587,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -616,14 +597,13 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['site_id', 'rack_id', 'device_id'],
|
||||
['type', 'status', 'color'],
|
||||
['type', 'status', 'color', 'length', 'length_unit'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -631,8 +611,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -641,8 +620,17 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
'rack_id': '$rack_id',
|
||||
},
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=add_blank_choice(CableTypeChoices),
|
||||
@@ -657,37 +645,32 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
color = ColorField(
|
||||
required=False
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
'rack_id': '$rack_id',
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
length = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
length_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPanelFilterForm(CustomFieldModelFilterForm):
|
||||
class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = PowerPanel
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
('region_id', 'site_group_id', 'site_id', 'location_id'),
|
||||
('contact', 'contact_role')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -696,8 +679,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@@ -706,8 +688,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
label=_('Location')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -723,14 +704,12 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -738,8 +717,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
power_panel_id = DynamicModelMultipleChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
@@ -748,8 +726,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Power panel'),
|
||||
fetch_trigger='open'
|
||||
label=_('Power panel')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -758,8 +735,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Rack'),
|
||||
fetch_trigger='open'
|
||||
label=_('Rack')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=PowerFeedStatusChoices,
|
||||
@@ -990,8 +966,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
serial = forms.CharField(
|
||||
required=False
|
||||
@@ -1016,8 +991,7 @@ class ConsoleConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1025,8 +999,7 @@ class ConsoleConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1034,8 +1007,7 @@ class ConsoleConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
|
||||
|
||||
@@ -1043,8 +1015,7 @@ class PowerConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1052,8 +1023,7 @@ class PowerConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1061,8 +1031,7 @@ class PowerConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
|
||||
|
||||
@@ -1070,8 +1039,7 @@ class InterfaceConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1079,8 +1047,7 @@ class InterfaceConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1088,6 +1055,5 @@ class InterfaceConnectionFilterForm(FilterForm):
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
|
||||
@@ -301,16 +301,14 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -318,24 +316,21 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
},
|
||||
fetch_trigger='open'
|
||||
}
|
||||
)
|
||||
units = NumericArrayField(
|
||||
base_field=forms.IntegerField(),
|
||||
@@ -349,8 +344,7 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
fetch_trigger='open'
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -611,11 +605,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
# can be flipped from one face to another.
|
||||
self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
|
||||
|
||||
# Limit platform by manufacturer
|
||||
self.fields['platform'].queryset = Platform.objects.filter(
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
|
||||
31
netbox/dcim/migrations/0144_fix_cable_abs_length.py
Normal file
31
netbox/dcim/migrations/0144_fix_cable_abs_length.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.utils import to_meters
|
||||
|
||||
|
||||
def recalculate_abs_length(apps, schema_editor):
|
||||
"""
|
||||
Recalculate absolute lengths for all cables with a length and length unit defined. Fixes
|
||||
incorrectly calculated values as reported under bug #8377.
|
||||
"""
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
cables = Cable.objects.filter(length__isnull=False).exclude(length_unit='')
|
||||
for cable in cables:
|
||||
cable._abs_length = to_meters(cable.length, cable.length_unit)
|
||||
|
||||
Cable.objects.bulk_update(cables, ['_abs_length'], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0143_remove_primary_for_related_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=recalculate_abs_length,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -5,42 +5,3 @@ from .devices import *
|
||||
from .power import *
|
||||
from .racks import *
|
||||
from .sites import *
|
||||
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'Cable',
|
||||
'CablePath',
|
||||
'LinkTermination',
|
||||
'ConsolePort',
|
||||
'ConsolePortTemplate',
|
||||
'ConsoleServerPort',
|
||||
'ConsoleServerPortTemplate',
|
||||
'Device',
|
||||
'DeviceBay',
|
||||
'DeviceBayTemplate',
|
||||
'DeviceRole',
|
||||
'DeviceType',
|
||||
'FrontPort',
|
||||
'FrontPortTemplate',
|
||||
'Interface',
|
||||
'InterfaceTemplate',
|
||||
'InventoryItem',
|
||||
'Location',
|
||||
'Manufacturer',
|
||||
'Platform',
|
||||
'PowerFeed',
|
||||
'PowerOutlet',
|
||||
'PowerOutletTemplate',
|
||||
'PowerPanel',
|
||||
'PowerPort',
|
||||
'PowerPortTemplate',
|
||||
'Rack',
|
||||
'RackReservation',
|
||||
'RackRole',
|
||||
'RearPort',
|
||||
'RearPortTemplate',
|
||||
'Region',
|
||||
'Site',
|
||||
'SiteGroup',
|
||||
'VirtualChassis',
|
||||
)
|
||||
|
||||
@@ -670,10 +670,11 @@ class Device(PrimaryModel, 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:
|
||||
|
||||
@@ -738,8 +739,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
if hasattr(self, 'device_type') and self.platform:
|
||||
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||
raise ValidationError({
|
||||
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||
'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
|
||||
f"this device's type belongs to {self.device_type.manufacturer}."
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
|
||||
@@ -412,7 +412,7 @@ class Rack(PrimaryModel):
|
||||
available_units.remove(u)
|
||||
|
||||
occupied_unit_count = self.u_height - len(available_units)
|
||||
percentage = int(float(occupied_unit_count) / self.u_height * 100)
|
||||
percentage = float(occupied_unit_count) / self.u_height * 100
|
||||
|
||||
return percentage
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ __all__ = (
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
return device.name or str(device.device_type)
|
||||
if device.virtual_chassis:
|
||||
return f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
return device.name
|
||||
else:
|
||||
return str(device.device_type)
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
@@ -121,10 +126,16 @@ class RackElevationSVG:
|
||||
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||
rect = drawing.rect(start, end, class_="slot blocked")
|
||||
rect.set_desc(self._get_device_description(device))
|
||||
drawing.add(rect)
|
||||
drawing.add(drawing.text(get_device_name(device), insert=text))
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
||||
target='_top',
|
||||
fill='black'
|
||||
)
|
||||
)
|
||||
link.set_desc(self._get_device_description(device))
|
||||
link.add(drawing.rect(start, end, class_="slot blocked"))
|
||||
link.add(drawing.text(get_device_name(device), insert=text))
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
@@ -135,10 +146,10 @@ class RackElevationSVG:
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
link.add(image)
|
||||
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
|
||||
@@ -23,6 +23,12 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Side A'
|
||||
)
|
||||
rack_a = tables.Column(
|
||||
accessor=Accessor('termination_a__device__rack'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Rack A'
|
||||
)
|
||||
termination_a = tables.Column(
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
@@ -35,6 +41,12 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Side B'
|
||||
)
|
||||
rack_b = tables.Column(
|
||||
accessor=Accessor('termination_b__device__rack'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Rack B'
|
||||
)
|
||||
termination_b = tables.Column(
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
@@ -45,7 +57,7 @@ class CableTable(BaseTable):
|
||||
tenant = TenantColumn()
|
||||
length = TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by='_abs_length'
|
||||
order_by=('_abs_length', 'length_unit')
|
||||
)
|
||||
color = ColorColumn()
|
||||
tags = TagColumn(
|
||||
@@ -55,8 +67,8 @@ class CableTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags',
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
|
||||
@@ -97,7 +97,7 @@ class DeviceRoleTable(BaseTable):
|
||||
model = DeviceRole
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
|
||||
|
||||
@@ -130,7 +130,7 @@ class PlatformTable(BaseTable):
|
||||
model = Platform
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
|
||||
'description', 'tags', 'actions',
|
||||
'description', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
|
||||
@@ -194,6 +194,9 @@ class DeviceTable(BaseTable):
|
||||
vc_priority = tables.Column(
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
@@ -204,7 +207,8 @@ class DeviceTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
@@ -260,7 +264,7 @@ class CableTerminationTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
cable_color = ColorColumn(
|
||||
accessor='cable.color',
|
||||
accessor='cable__color',
|
||||
orderable=False,
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
@@ -275,7 +279,7 @@ class CableTerminationTable(BaseTable):
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = TemplateColumn(
|
||||
accessor='_path.last_node',
|
||||
accessor='_path__last_node',
|
||||
template_code=LINKTERMINATION,
|
||||
verbose_name='Connection',
|
||||
orderable=False
|
||||
@@ -297,7 +301,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'link_peer', 'connection', 'tags',
|
||||
'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
@@ -341,7 +345,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
@@ -386,7 +390,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw',
|
||||
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
@@ -437,7 +441,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
@@ -515,7 +519,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@@ -586,7 +590,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
@@ -637,7 +641,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'link_peer', 'tags',
|
||||
'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
||||
|
||||
@@ -676,6 +680,15 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
device_role = ColoredLabelColumn(
|
||||
accessor=Accessor('installed_device__device_role'),
|
||||
verbose_name='Role'
|
||||
)
|
||||
device_type = tables.Column(
|
||||
accessor=Accessor('installed_device__device_type'),
|
||||
linkify=True,
|
||||
verbose_name='Type'
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
order_by=Accessor('installed_device__status')
|
||||
@@ -689,7 +702,11 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
@@ -736,7 +753,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered', 'tags',
|
||||
'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
|
||||
@@ -788,5 +805,5 @@ class VirtualChassisTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
|
||||
|
||||
@@ -41,6 +41,9 @@ class ManufacturerTable(BaseTable):
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:manufacturer_list'
|
||||
)
|
||||
@@ -50,7 +53,7 @@ class ManufacturerTable(BaseTable):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'actions',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
@@ -67,6 +70,9 @@ class DeviceTypeTable(BaseTable):
|
||||
linkify=True,
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
manufacturer = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
is_full_depth = BooleanColumn(
|
||||
verbose_name='Full Depth'
|
||||
)
|
||||
@@ -84,7 +90,7 @@ class DeviceTypeTable(BaseTable):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'airflow', 'comments', 'instance_count', 'tags',
|
||||
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||
@@ -111,8 +117,7 @@ class ComponentTemplateTable(BaseTable):
|
||||
class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleports'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -124,8 +129,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleserverports'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -137,8 +141,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_powerports'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -150,8 +153,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
class PowerOutletTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutletTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_poweroutlets'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -166,8 +168,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InterfaceTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_interfaces'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -183,8 +184,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_frontports'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -197,8 +197,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(
|
||||
model=RearPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_rearports'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@@ -210,8 +209,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBayTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_devicebays'
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
|
||||
@@ -27,13 +27,16 @@ class PowerPanelTable(BaseTable):
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerpanel_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
@@ -72,7 +75,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
|
||||
'comments', 'tags',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
||||
@@ -31,7 +31,10 @@ class RackRoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackRole
|
||||
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -72,6 +75,9 @@ class RackTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
@@ -87,12 +93,13 @@ class RackTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
|
||||
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
|
||||
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
'get_utilization', 'get_power_utilization',
|
||||
'get_utilization',
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +134,7 @@ class RackReservationTable(BaseTable):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
|
||||
|
||||
@@ -29,6 +29,9 @@ class RegionTable(BaseTable):
|
||||
url_params={'region_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:region_list'
|
||||
)
|
||||
@@ -36,7 +39,7 @@ class RegionTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Region
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -54,6 +57,9 @@ class SiteGroupTable(BaseTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:sitegroup_list'
|
||||
)
|
||||
@@ -61,7 +67,7 @@ class SiteGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -82,12 +88,19 @@ class SiteTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
asn_count = LinkedCountColumn(
|
||||
accessor=tables.A('asns.count'),
|
||||
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()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
@@ -96,9 +109,9 @@ class SiteTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'comments', 'tags',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
@@ -126,6 +139,9 @@ class LocationTable(BaseTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
@@ -137,7 +153,7 @@ class LocationTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
|
||||
'actions',
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
|
||||
|
||||
@@ -9,7 +9,8 @@ LINKTERMINATION = """
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
|
||||
{% load helpers %}
|
||||
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
@@ -297,6 +298,8 @@ REARPORT_BUTTONS = """
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
|
||||
@@ -9,6 +9,7 @@ from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
|
||||
@@ -1239,10 +1240,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
'name': 'Interface 4',
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'tx_power': 10,
|
||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||
'untagged_vlan': vlans[2].pk,
|
||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
@@ -1250,10 +1249,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'bridge': interfaces[0].pk,
|
||||
'tx_power': 10,
|
||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||
'untagged_vlan': vlans[2].pk,
|
||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
@@ -1261,10 +1258,24 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
'type': 'virtual',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'parent': interfaces[1].pk,
|
||||
'tx_power': 10,
|
||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||
'untagged_vlan': vlans[2].pk,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Interface 7',
|
||||
'type': InterfaceTypeChoices.TYPE_80211A,
|
||||
'tx_power': 10,
|
||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||
'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Interface 8',
|
||||
'type': InterfaceTypeChoices.TYPE_80211A,
|
||||
'tx_power': 10,
|
||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||
'rf_channel': "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com', 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', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com', description='foobar2'),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
@@ -201,6 +201,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'contact_email': ['contact1@example.com', 'contact2@example.com']}
|
||||
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)
|
||||
@@ -329,8 +333,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)
|
||||
@@ -347,6 +351,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()
|
||||
@@ -570,8 +578,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)
|
||||
@@ -604,6 +612,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]}
|
||||
@@ -1088,8 +1100,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)
|
||||
@@ -1112,6 +1124,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()
|
||||
|
||||
@@ -27,13 +27,7 @@ from virtualization.models import VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||
from .models import (
|
||||
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
|
||||
PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
SiteGroup, VirtualChassis,
|
||||
)
|
||||
from .models import *
|
||||
|
||||
|
||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
@@ -51,10 +45,21 @@ class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
class DeviceTypeComponentsView(DeviceComponentsView):
|
||||
queryset = DeviceType.objects.all()
|
||||
template_name = 'dcim/devicetype/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(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,
|
||||
}
|
||||
|
||||
|
||||
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
"""
|
||||
@@ -323,6 +328,11 @@ class SiteView(generic.ObjectView):
|
||||
'device_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=instance)
|
||||
nonracked_devices = Device.objects.filter(
|
||||
site=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
|
||||
asn_count = asns.count()
|
||||
@@ -333,6 +343,7 @@ class SiteView(generic.ObjectView):
|
||||
'stats': stats,
|
||||
'locations': locations,
|
||||
'asns': asns,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -410,11 +421,17 @@ class LocationView(generic.ObjectView):
|
||||
).filter(pk__in=location_ids).exclude(pk=instance.pk)
|
||||
child_locations_table = tables.LocationTable(child_locations)
|
||||
paginate_table(child_locations_table, request)
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
return {
|
||||
'rack_count': rack_count,
|
||||
'device_count': device_count,
|
||||
'child_locations_table': child_locations_table,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -592,8 +609,8 @@ class RackView(generic.ObjectView):
|
||||
peer_racks = peer_racks.filter(location=instance.location)
|
||||
else:
|
||||
peer_racks = peer_racks.filter(location__isnull=True)
|
||||
next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
|
||||
next_rack = peer_racks.filter(_name__gt=instance._name).first()
|
||||
prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first()
|
||||
|
||||
reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
|
||||
power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(
|
||||
@@ -798,48 +815,56 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
|
||||
child_model = ConsolePortTemplate
|
||||
table = tables.ConsolePortTemplateTable
|
||||
filterset = filtersets.ConsolePortTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_consoleports'
|
||||
|
||||
|
||||
class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
|
||||
child_model = ConsoleServerPortTemplate
|
||||
table = tables.ConsoleServerPortTemplateTable
|
||||
filterset = filtersets.ConsoleServerPortTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_consoleserverports'
|
||||
|
||||
|
||||
class DeviceTypePowerPortsView(DeviceTypeComponentsView):
|
||||
child_model = PowerPortTemplate
|
||||
table = tables.PowerPortTemplateTable
|
||||
filterset = filtersets.PowerPortTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_powerports'
|
||||
|
||||
|
||||
class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
|
||||
child_model = PowerOutletTemplate
|
||||
table = tables.PowerOutletTemplateTable
|
||||
filterset = filtersets.PowerOutletTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_poweroutlets'
|
||||
|
||||
|
||||
class DeviceTypeInterfacesView(DeviceTypeComponentsView):
|
||||
child_model = InterfaceTemplate
|
||||
table = tables.InterfaceTemplateTable
|
||||
filterset = filtersets.InterfaceTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_interfaces'
|
||||
|
||||
|
||||
class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
|
||||
child_model = FrontPortTemplate
|
||||
table = tables.FrontPortTemplateTable
|
||||
filterset = filtersets.FrontPortTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_frontports'
|
||||
|
||||
|
||||
class DeviceTypeRearPortsView(DeviceTypeComponentsView):
|
||||
child_model = RearPortTemplate
|
||||
table = tables.RearPortTemplateTable
|
||||
filterset = filtersets.RearPortTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_rearports'
|
||||
|
||||
|
||||
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
|
||||
child_model = DeviceBayTemplate
|
||||
table = tables.DeviceBayTemplateTable
|
||||
filterset = filtersets.DeviceBayTemplateFilterSet
|
||||
viewname = 'dcim:devicetype_devicebays'
|
||||
|
||||
|
||||
class DeviceTypeEditView(generic.ObjectEditView):
|
||||
@@ -2022,8 +2047,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
|
||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||
device_bay.save()
|
||||
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
||||
return_url = self.get_return_url(request)
|
||||
|
||||
return redirect('dcim:device', pk=device_bay.device.pk)
|
||||
return redirect(return_url)
|
||||
|
||||
return render(request, 'dcim/devicebay_populate.html', {
|
||||
'device_bay': device_bay,
|
||||
|
||||
@@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer,
|
||||
NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
|
||||
NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
)
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
@@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
||||
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
'conditions', 'ssl_verification', 'ca_file_path',
|
||||
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -100,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
|
||||
model = CustomLink
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -118,7 +119,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
model = ExportTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
|
||||
'file_extension', 'as_attachment',
|
||||
'file_extension', 'as_attachment', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -132,7 +133,9 @@ class TagSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -4,6 +4,7 @@ from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
@@ -382,6 +383,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
|
||||
"""
|
||||
permission_classes = (IsAuthenticated,)
|
||||
queryset = ContentType.objects.order_by('app_label', 'model')
|
||||
serializer_class = serializers.ContentTypeSerializer
|
||||
filterset_class = filtersets.ContentTypeFilterSet
|
||||
|
||||
@@ -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():
|
||||
@@ -103,7 +103,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():
|
||||
@@ -177,14 +177,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):
|
||||
@@ -317,6 +318,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
tag_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tags',
|
||||
queryset=Tag.objects.all(),
|
||||
label='Tag',
|
||||
)
|
||||
tag = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tags__slug',
|
||||
queryset=Tag.objects.all(),
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
@@ -44,7 +46,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
limit_choices_to=FeatureQuery('custom_links'),
|
||||
required=False
|
||||
)
|
||||
new_window = forms.NullBooleanField(
|
||||
@@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
||||
required=False
|
||||
)
|
||||
button_class = forms.ChoiceField(
|
||||
choices=CustomLinkButtonClassChoices,
|
||||
choices=add_blank_choice(CustomLinkButtonClassChoices),
|
||||
required=False,
|
||||
widget=StaticSelect()
|
||||
)
|
||||
@@ -71,7 +73,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
@@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
http_method = forms.ChoiceField(
|
||||
choices=WebhookHttpMethodChoices,
|
||||
required=False
|
||||
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||
required=False,
|
||||
label='HTTP method'
|
||||
)
|
||||
payload_url = forms.CharField(
|
||||
required=False
|
||||
required=False,
|
||||
label='Payload URL'
|
||||
)
|
||||
ssl_verification = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label='SSL verification'
|
||||
)
|
||||
secret = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
ca_file_path = forms.CharField(
|
||||
required=False
|
||||
required=False,
|
||||
label='CA file path'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False
|
||||
)
|
||||
comments = forms.CharField(
|
||||
|
||||
@@ -3,9 +3,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldCSVForm',
|
||||
@@ -22,6 +23,10 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
help_text="One or more assigned object types"
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=CustomFieldTypeChoices,
|
||||
help_text='Field data type (e.g. text, integer, etc.)'
|
||||
)
|
||||
choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
@@ -32,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
|
||||
'choices', 'weight',
|
||||
'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models import Q
|
||||
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
|
||||
from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldModelCSVForm',
|
||||
@@ -34,6 +34,9 @@ class CustomFieldsMixin:
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field()
|
||||
|
||||
@@ -41,10 +44,7 @@ class CustomFieldsMixin:
|
||||
"""
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
content_type = self._get_content_type()
|
||||
|
||||
# Append form fields; assign initial values if modifying and existing object
|
||||
for customfield in CustomField.objects.filter(content_types=content_type):
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
@@ -86,40 +86,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
|
||||
class CustomFieldModelBulkEditForm(BulkEditForm):
|
||||
class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False)
|
||||
|
||||
self.custom_fields = []
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
|
||||
for cf in custom_fields:
|
||||
def _append_customfield_fields(self):
|
||||
"""
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
# Annotate non-required custom fields as nullable
|
||||
if not cf.required:
|
||||
self.nullable_fields.append(cf.name)
|
||||
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
|
||||
# Annotate this as a custom field
|
||||
self.custom_fields.append(cf.name)
|
||||
if not customfield.required:
|
||||
self.nullable_fields.append(customfield.name)
|
||||
|
||||
self.fields[customfield.name] = self._get_form_field(customfield)
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields.append(customfield.name)
|
||||
|
||||
|
||||
class CustomFieldModelFilterForm(FilterForm):
|
||||
class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
self.custom_field_filters = []
|
||||
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
||||
)
|
||||
for cf in custom_fields:
|
||||
field_name = f'cf_{cf.name}'
|
||||
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
|
||||
self.custom_field_filters.append(field_name)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False)
|
||||
|
||||
@@ -62,7 +62,7 @@ class CustomLinkFilterForm(FilterForm):
|
||||
]
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
limit_choices_to=FeatureQuery('custom_links'),
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
@@ -83,7 +83,7 @@ class ExportTemplateFilterForm(FilterForm):
|
||||
]
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
required=False
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
@@ -109,7 +109,7 @@ class WebhookFilterForm(FilterForm):
|
||||
]
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
required=False
|
||||
)
|
||||
http_method = forms.MultipleChoiceField(
|
||||
@@ -155,7 +155,7 @@ class TagFilterForm(FilterForm):
|
||||
|
||||
class ConfigContextFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['q', 'tag_id'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['device_type_id', 'platform_id', 'role_id'],
|
||||
['cluster_group_id', 'cluster_id'],
|
||||
@@ -164,69 +164,57 @@ class ConfigContextFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Regions'),
|
||||
fetch_trigger='open'
|
||||
label=_('Regions')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site groups'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site groups')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Sites'),
|
||||
fetch_trigger='open'
|
||||
label=_('Sites')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device types'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device types')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Roles'),
|
||||
fetch_trigger='open'
|
||||
label=_('Roles')
|
||||
)
|
||||
platform_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
label=_('Platforms'),
|
||||
fetch_trigger='open'
|
||||
label=_('Platforms')
|
||||
)
|
||||
cluster_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster groups'),
|
||||
fetch_trigger='open'
|
||||
label=_('Cluster groups')
|
||||
)
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
label=_('Clusters'),
|
||||
fetch_trigger='open'
|
||||
label=_('Clusters')
|
||||
)
|
||||
tenant_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant groups'),
|
||||
fetch_trigger='open'
|
||||
label=_('Tenant groups')
|
||||
)
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant'),
|
||||
fetch_trigger='open'
|
||||
label=_('Tenant')
|
||||
)
|
||||
tag = DynamicModelMultipleChoiceField(
|
||||
tag_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
fetch_trigger='open'
|
||||
label=_('Tags')
|
||||
)
|
||||
|
||||
|
||||
@@ -263,8 +251,7 @@ class JournalEntryFilterForm(FilterForm):
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
)
|
||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -272,8 +259,7 @@ class JournalEntryFilterForm(FilterForm):
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
@@ -310,8 +296,7 @@ class ObjectChangeFilterForm(FilterForm):
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
)
|
||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -319,6 +304,5 @@ class ObjectChangeFilterForm(FilterForm):
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
|
||||
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
|
||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
|
||||
@@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
widgets = {
|
||||
'type': StaticSelect(),
|
||||
'filter_logic': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
@@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
('Templates', ('link_text', 'link_url')),
|
||||
)
|
||||
widgets = {
|
||||
'button_class': StaticSelect(),
|
||||
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
@@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
model = ExportTemplate
|
||||
fields = '__all__'
|
||||
fieldsets = (
|
||||
('Custom Link', ('name', 'content_type', 'description')),
|
||||
('Export Template', ('name', 'content_type', 'description')),
|
||||
('Template', ('template_code',)),
|
||||
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
@@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
model = Webhook
|
||||
fields = '__all__'
|
||||
fieldsets = (
|
||||
('Webhook', ('name', 'enabled')),
|
||||
('Assigned Models', ('content_types',)),
|
||||
('Webhook', ('name', 'content_types', 'enabled')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
('HTTP Request', (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
@@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
('Conditions', ('conditions',)),
|
||||
('SSL', ('ssl_verification', 'ca_file_path')),
|
||||
)
|
||||
labels = {
|
||||
'type_create': 'Creations',
|
||||
'type_update': 'Updates',
|
||||
'type_delete': 'Deletions',
|
||||
}
|
||||
widgets = {
|
||||
'http_method': StaticSelect(),
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
@@ -70,10 +70,23 @@ class Command(BaseCommand):
|
||||
return namespace
|
||||
|
||||
def handle(self, **options):
|
||||
namespace = self.get_namespace()
|
||||
|
||||
# If Python code has been passed, execute it and exit.
|
||||
if options['command']:
|
||||
exec(options['command'], self.get_namespace())
|
||||
exec(options['command'], namespace)
|
||||
return
|
||||
|
||||
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
|
||||
# Try to enable tab-complete
|
||||
try:
|
||||
import readline
|
||||
import rlcompleter
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
else:
|
||||
readline.set_completer(rlcompleter.Completer(namespace).complete)
|
||||
readline.parse_and_bind('tab: complete')
|
||||
|
||||
# Run interactive shell
|
||||
shell = code.interact(banner=BANNER_TEXT, local=namespace)
|
||||
return shell
|
||||
|
||||
21
netbox/extras/migrations/0067_customfield_min_max_values.py
Normal file
21
netbox/extras/migrations/0067_customfield_min_max_values.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0066_customfield_name_validation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='validation_maximum',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='validation_minimum',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect,
|
||||
add_blank_choice,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
@@ -96,13 +97,13 @@ class CustomField(ChangeLoggedModel):
|
||||
default=100,
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
validation_minimum = models.PositiveIntegerField(
|
||||
validation_minimum = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Minimum value',
|
||||
help_text='Minimum allowed value (for numeric fields)'
|
||||
)
|
||||
validation_maximum = models.PositiveIntegerField(
|
||||
validation_maximum = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Maximum value',
|
||||
@@ -238,7 +239,7 @@ class CustomField(ChangeLoggedModel):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
|
||||
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
@@ -287,7 +288,7 @@ class CustomField(ChangeLoggedModel):
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelect()
|
||||
)
|
||||
else:
|
||||
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
|
||||
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
|
||||
field = field_class(
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
|
||||
)
|
||||
|
||||
@@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:customlink', args=[self.pk])
|
||||
|
||||
def render(self, context):
|
||||
"""
|
||||
Render the CustomLink given the provided context, and return the text, link, and link_target.
|
||||
|
||||
:param context: The context passed to Jinja2
|
||||
"""
|
||||
text = render_jinja2(self.link_text, context)
|
||||
if not text:
|
||||
return {}
|
||||
link = render_jinja2(self.link_url, context)
|
||||
link_target = ' target="_blank"' if self.new_window else ''
|
||||
|
||||
return {
|
||||
'text': text,
|
||||
'link': link,
|
||||
'link_target': link_target,
|
||||
}
|
||||
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class ExportTemplate(ChangeLoggedModel):
|
||||
|
||||
@@ -21,7 +21,7 @@ from extras.models import JobResult
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import change_logging
|
||||
from .forms import ScriptForm
|
||||
|
||||
@@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
# Set field choices, adding a blank choice to avoid forced selections
|
||||
self.field_attrs['choices'] = add_blank_choice(choices)
|
||||
|
||||
|
||||
class MultiChoiceVar(ChoiceVar):
|
||||
class MultiChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Like ChoiceVar, but allows for the selection of multiple choices.
|
||||
"""
|
||||
form_field = forms.MultipleChoiceField
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
@@ -253,6 +259,10 @@ class BaseScript:
|
||||
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
|
||||
functionality for use in other subclasses.
|
||||
"""
|
||||
|
||||
# Prevent django from instantiating the class on all accesses
|
||||
do_not_call_in_templates = True
|
||||
|
||||
class Meta:
|
||||
pass
|
||||
|
||||
@@ -274,7 +284,7 @@ class BaseScript:
|
||||
|
||||
@classproperty
|
||||
def name(self):
|
||||
return getattr(self.Meta, 'name', self.__class__.__name__)
|
||||
return getattr(self.Meta, 'name', self.__name__)
|
||||
|
||||
@classproperty
|
||||
def full_name(self):
|
||||
@@ -290,12 +300,21 @@ class BaseScript:
|
||||
|
||||
@classmethod
|
||||
def _get_vars(cls):
|
||||
vars = OrderedDict()
|
||||
vars = {}
|
||||
for name, attr in cls.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
return vars
|
||||
# Order variables according to field_order
|
||||
field_order = getattr(cls.Meta, 'field_order', None)
|
||||
if not field_order:
|
||||
return vars
|
||||
ordered_vars = {
|
||||
field: vars.pop(field) for field in field_order if field in vars
|
||||
}
|
||||
ordered_vars.update(vars)
|
||||
|
||||
return ordered_vars
|
||||
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
|
||||
@@ -29,8 +29,13 @@ CONFIGCONTEXT_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_FULL_NAME = """
|
||||
{% load helpers %}
|
||||
{{ record.user.get_full_name|placeholder }}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.changed_object.get_absolute_url %}
|
||||
{% 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 }}
|
||||
@@ -58,7 +63,7 @@ class CustomFieldTable(BaseTable):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'choices',
|
||||
'description', 'filter_logic', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
|
||||
|
||||
@@ -79,7 +84,7 @@ class CustomLinkTable(BaseTable):
|
||||
model = CustomLink
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
|
||||
|
||||
@@ -100,6 +105,7 @@ class ExportTemplateTable(BaseTable):
|
||||
model = ExportTemplate
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
@@ -134,7 +140,7 @@ class WebhookTable(BaseTable):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
@@ -156,7 +162,7 @@ class TagTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Tag
|
||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -193,7 +199,7 @@ class ConfigContextTable(BaseTable):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
|
||||
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
|
||||
@@ -203,6 +209,14 @@ class ObjectChangeTable(BaseTable):
|
||||
linkify=True,
|
||||
format=settings.SHORT_DATETIME_FORMAT
|
||||
)
|
||||
user_name = tables.Column(
|
||||
verbose_name='Username'
|
||||
)
|
||||
full_name = tables.TemplateColumn(
|
||||
template_code=OBJECTCHANGE_FULL_NAME,
|
||||
verbose_name='Full Name',
|
||||
orderable=False
|
||||
)
|
||||
action = ChoiceFieldColumn()
|
||||
changed_object_type = ContentTypeColumn(
|
||||
verbose_name='Type'
|
||||
@@ -218,7 +232,7 @@ class ObjectChangeTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ObjectChange
|
||||
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
|
||||
|
||||
class ObjectJournalTable(BaseTable):
|
||||
|
||||
@@ -62,16 +62,14 @@ def custom_links(context, obj):
|
||||
# Add non-grouped links
|
||||
else:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.link_text, link_context)
|
||||
if text_rendered:
|
||||
link_rendered = render_jinja2(cl.link_url, link_context)
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
template_code += LINK_BUTTON.format(
|
||||
link_rendered, link_target, cl.button_class, text_rendered
|
||||
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
||||
)
|
||||
except Exception as e:
|
||||
template_code += '<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{}">' \
|
||||
'<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name)
|
||||
template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
|
||||
f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
|
||||
|
||||
# Add grouped links to template
|
||||
for group, links in group_names.items():
|
||||
@@ -80,17 +78,15 @@ def custom_links(context, obj):
|
||||
|
||||
for cl in links:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.link_text, link_context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
link_rendered = render_jinja2(cl.link_url, link_context)
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(link_rendered, link_target, text_rendered)
|
||||
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
|
||||
)
|
||||
except Exception as e:
|
||||
links_rendered.append(
|
||||
'<li><a class="dropdown-item" disabled="disabled" title="{}"><span class="text-muted">'
|
||||
'<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name)
|
||||
f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
|
||||
f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
|
||||
)
|
||||
|
||||
if links_rendered:
|
||||
|
||||
@@ -608,7 +608,6 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
class ContentTypeTest(APITestCase):
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype'])
|
||||
def test_list_objects(self):
|
||||
contenttype_count = ContentType.objects.count()
|
||||
|
||||
@@ -616,7 +615,6 @@ class ContentTypeTest(APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], contenttype_count)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype'])
|
||||
def test_get_object(self):
|
||||
contenttype = ContentType.objects.first()
|
||||
|
||||
|
||||
@@ -25,49 +25,68 @@ class CustomFieldTest(TestCase):
|
||||
def test_simple_fields(self):
|
||||
DATA = (
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_TEXT,
|
||||
'field_value': 'Foobar!',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_TEXT,
|
||||
},
|
||||
'value': 'Foobar!',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
'field_value': 'Text with **Markdown**',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
},
|
||||
'value': 'Text with **Markdown**',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'field_value': 0,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
},
|
||||
'value': 0,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'field_value': 42,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': 1,
|
||||
'validation_maximum': 100,
|
||||
},
|
||||
'value': 42,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
'field_value': True,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': -100,
|
||||
'validation_maximum': -1,
|
||||
},
|
||||
'value': -42,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
'field_value': False,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': True,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_DATE,
|
||||
'field_value': '2016-06-23',
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': False,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_URL,
|
||||
'field_value': 'http://example.com/',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_DATE,
|
||||
},
|
||||
'value': '2016-06-23',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_JSON,
|
||||
'field_value': '{"foo": 1, "bar": 2}',
|
||||
'empty_value': 'null',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_URL,
|
||||
},
|
||||
'value': 'http://example.com/',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_JSON,
|
||||
},
|
||||
'value': '{"foo": 1, "bar": 2}',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -76,7 +95,7 @@ class CustomFieldTest(TestCase):
|
||||
for data in DATA:
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(type=data['field_type'], name='my_field', required=False)
|
||||
cf = CustomField(name='my_field', required=False, **data['field'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
@@ -85,12 +104,12 @@ class CustomFieldTest(TestCase):
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = data['field_value']
|
||||
site.custom_field_data[cf.name] = data['value']
|
||||
site.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
|
||||
self.assertEqual(site.custom_field_data[cf.name], data['value'])
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
@@ -103,13 +122,14 @@ class CustomFieldTest(TestCase):
|
||||
|
||||
def test_select_field(self):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
choices = ['Option A', 'Option B', 'Option C']
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field',
|
||||
required=False,
|
||||
choices=['Option A', 'Option B', 'Option C']
|
||||
choices=choices
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
@@ -119,12 +139,47 @@ class CustomFieldTest(TestCase):
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = 'Option A'
|
||||
site.custom_field_data[cf.name] = choices[0]
|
||||
site.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
|
||||
self.assertEqual(site.custom_field_data[cf.name], choices[0])
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
site.save()
|
||||
site.refresh_from_db()
|
||||
self.assertIsNone(site.custom_field_data.get(cf.name))
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
|
||||
def test_multiselect_field(self):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
choices = ['Option A', 'Option B', 'Option C']
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
name='my_field',
|
||||
required=False,
|
||||
choices=choices
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = [choices[0], choices[1]]
|
||||
site.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], [choices[0], choices[1]])
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
@@ -578,6 +633,9 @@ class CustomFieldImportTest(TestCase):
|
||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
)
|
||||
for cf in custom_fields:
|
||||
cf.save()
|
||||
@@ -588,19 +646,20 @@ class CustomFieldImportTest(TestCase):
|
||||
Import a Site in CSV format, including a value for each CustomField.
|
||||
"""
|
||||
data = (
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
|
||||
)
|
||||
csv_data = '\n'.join(','.join(row) for row in data)
|
||||
|
||||
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(Site.objects.count(), 3)
|
||||
|
||||
# Validate data for site 1
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
self.assertEqual(len(site1.custom_field_data), 8)
|
||||
self.assertEqual(len(site1.custom_field_data), 9)
|
||||
self.assertEqual(site1.custom_field_data['text'], 'ABC')
|
||||
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
|
||||
self.assertEqual(site1.custom_field_data['integer'], 123)
|
||||
@@ -609,10 +668,11 @@ class CustomFieldImportTest(TestCase):
|
||||
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
|
||||
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
|
||||
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
|
||||
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
|
||||
|
||||
# Validate data for site 2
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
self.assertEqual(len(site2.custom_field_data), 8)
|
||||
self.assertEqual(len(site2.custom_field_data), 9)
|
||||
self.assertEqual(site2.custom_field_data['text'], 'DEF')
|
||||
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
|
||||
self.assertEqual(site2.custom_field_data['integer'], 456)
|
||||
@@ -621,6 +681,7 @@ class CustomFieldImportTest(TestCase):
|
||||
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
||||
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
|
||||
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
|
||||
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
|
||||
|
||||
# No custom field data should be set for site 3
|
||||
site3 = Site.objects.get(name='Site 3')
|
||||
|
||||
@@ -12,7 +12,7 @@ from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
@@ -153,8 +153,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)
|
||||
@@ -167,6 +167,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()
|
||||
@@ -429,6 +433,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
for i in range(0, 3):
|
||||
is_active = bool(i % 2)
|
||||
c = ConfigContext.objects.create(
|
||||
@@ -446,6 +452,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
c.clusters.set([clusters[i]])
|
||||
c.tenant_groups.set([tenant_groups[i]])
|
||||
c.tenants.set([tenants[i]])
|
||||
c.tags.set([tags[i]])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Config Context 1', 'Config Context 2']}
|
||||
@@ -516,13 +523,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_(self):
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tags(self):
|
||||
tags = Tag.objects.all()[:2]
|
||||
params = {'tag_id': [tags[0].pk, tags[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tag': [tags[0].slug, tags[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Tag.objects.all()
|
||||
@@ -532,8 +546,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)
|
||||
@@ -557,6 +571,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)
|
||||
|
||||
@@ -39,10 +39,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,weight,filter_logic,choices',
|
||||
'field4,Field 4,text,dcim.site,100,exact,',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C"',
|
||||
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -10,6 +10,7 @@ from rq import Worker
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
@@ -447,7 +448,8 @@ class ObjectChangeLogView(View):
|
||||
)
|
||||
objectchanges_table = tables.ObjectChangeTable(
|
||||
data=objectchanges,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
paginate_table(objectchanges_table, request)
|
||||
|
||||
@@ -471,6 +473,7 @@ class ObjectChangeLogView(View):
|
||||
class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
model_form = forms.ImageAttachmentForm
|
||||
template_name = 'extras/imageattachment_edit.html'
|
||||
|
||||
def alter_obj(self, instance, request, args, kwargs):
|
||||
if not instance.pk:
|
||||
@@ -693,16 +696,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get(self, request, job_result_pk):
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
|
||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
|
||||
|
||||
# Retrieve the Report and attach the JobResult to it
|
||||
module, report_name = jobresult.name.split('.')
|
||||
module, report_name = result.name.split('.')
|
||||
report = get_report(module, report_name)
|
||||
report.result = jobresult
|
||||
report.result = result
|
||||
|
||||
# If this is an HTMX request, return only the result HTML
|
||||
if is_htmx(request):
|
||||
response = render(request, 'extras/htmx/report_result.html', {
|
||||
'report': report,
|
||||
'result': result,
|
||||
})
|
||||
if result.completed:
|
||||
response.status_code = 286
|
||||
return response
|
||||
|
||||
return render(request, 'extras/report_result.html', {
|
||||
'report': report,
|
||||
'result': jobresult,
|
||||
'result': result,
|
||||
})
|
||||
|
||||
|
||||
@@ -820,6 +833,16 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
|
||||
|
||||
script = self._get_script(result.name)
|
||||
|
||||
# If this is an HTMX request, return only the result HTML
|
||||
if is_htmx(request):
|
||||
response = render(request, 'extras/htmx/script_result.html', {
|
||||
'script': script,
|
||||
'result': result,
|
||||
})
|
||||
if result.completed:
|
||||
response.status_code = 286
|
||||
return response
|
||||
|
||||
return render(request, 'extras/script_result.html', {
|
||||
'script': script,
|
||||
'result': result,
|
||||
|
||||
@@ -126,7 +126,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
|
||||
|
||||
|
||||
class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
|
||||
group = NestedFHRPGroupSerializer()
|
||||
interface_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
|
||||
@@ -135,6 +135,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
|
||||
PROTOCOL_HSRP = 'hsrp'
|
||||
PROTOCOL_GLBP = 'glbp'
|
||||
PROTOCOL_CARP = 'carp'
|
||||
PROTOCOL_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
(PROTOCOL_VRRP2, 'VRRPv2'),
|
||||
@@ -142,6 +143,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
|
||||
(PROTOCOL_HSRP, 'HSRP'),
|
||||
(PROTOCOL_GLBP, 'GLBP'),
|
||||
(PROTOCOL_CARP, 'CARP'),
|
||||
(PROTOCOL_OTHER, 'Other'),
|
||||
)
|
||||
|
||||
|
||||
@@ -187,8 +189,10 @@ class ServiceProtocolChoices(ChoiceSet):
|
||||
|
||||
PROTOCOL_TCP = 'tcp'
|
||||
PROTOCOL_UDP = 'udp'
|
||||
PROTOCOL_SCTP = 'sctp'
|
||||
|
||||
CHOICES = (
|
||||
(PROTOCOL_TCP, 'TCP'),
|
||||
(PROTOCOL_UDP, 'UDP'),
|
||||
(PROTOCOL_SCTP, 'SCTP'),
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
|
||||
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique']
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -117,7 +117,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RouteTarget
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
|
||||
class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
@@ -155,7 +155,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'date_added']
|
||||
fields = ['id', 'date_added', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -203,12 +203,16 @@ 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():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(asn=int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@@ -221,7 +225,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -330,7 +334,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
vlan_vid = django_filters.NumberFilter(
|
||||
field_name='vlan__vid',
|
||||
label='VLAN number (1-4095)',
|
||||
label='VLAN number (1-4094)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Role.objects.all(),
|
||||
@@ -350,7 +354,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, 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():
|
||||
@@ -456,7 +460,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = ['id']
|
||||
fields = ['id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -835,7 +839,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'vid', 'name']
|
||||
fields = ['id', 'vid', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -887,7 +891,7 @@ class ServiceFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'name', 'protocol']
|
||||
fields = ['id', 'name', 'protocol', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
)
|
||||
dns_name = forms.CharField(
|
||||
max_length=255,
|
||||
required=False
|
||||
required=False,
|
||||
label='DNS name'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
|
||||
@@ -375,7 +375,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
|
||||
model = VLAN
|
||||
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
|
||||
help_texts = {
|
||||
'vid': 'Numeric VLAN ID (1-4095)',
|
||||
'vid': 'Numeric VLAN ID (1-4094)',
|
||||
'name': 'VLAN name',
|
||||
}
|
||||
|
||||
|
||||
@@ -48,14 +48,12 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
import_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Import targets'),
|
||||
fetch_trigger='open'
|
||||
label=_('Import targets')
|
||||
)
|
||||
export_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Export targets'),
|
||||
fetch_trigger='open'
|
||||
label=_('Export targets')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -70,14 +68,12 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
importing_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Imported by VRF'),
|
||||
fetch_trigger='open'
|
||||
label=_('Imported by VRF')
|
||||
)
|
||||
exporting_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Exported by VRF'),
|
||||
fetch_trigger='open'
|
||||
label=_('Exported by VRF')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -110,8 +106,7 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
label=_('RIR'),
|
||||
fetch_trigger='open'
|
||||
label=_('RIR')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -127,14 +122,12 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
label=_('RIR'),
|
||||
fetch_trigger='open'
|
||||
label=_('RIR')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
|
||||
|
||||
@@ -180,14 +173,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
null_option='Global'
|
||||
)
|
||||
present_in_vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Present in VRF'),
|
||||
fetch_trigger='open'
|
||||
label=_('Present in VRF')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
@@ -197,14 +188,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -213,15 +202,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
is_pool = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -257,8 +244,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
null_option='Global'
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
@@ -269,8 +255,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -308,14 +293,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VRF'),
|
||||
null_option='Global',
|
||||
fetch_trigger='open'
|
||||
null_option='Global'
|
||||
)
|
||||
present_in_vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label=_('Present in VRF'),
|
||||
fetch_trigger='open'
|
||||
label=_('Present in VRF')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=IPAddressStatusChoices,
|
||||
@@ -376,32 +359,27 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
sitegroup = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
location = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
label=_('Location')
|
||||
)
|
||||
rack = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack'),
|
||||
fetch_trigger='open'
|
||||
label=_('Rack')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -417,14 +395,12 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
fetch_trigger='open'
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -433,8 +409,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region': '$region'
|
||||
},
|
||||
label=_('Site'),
|
||||
fetch_trigger='open'
|
||||
label=_('Site')
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
@@ -443,8 +418,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
query_params={
|
||||
'region': '$region'
|
||||
},
|
||||
label=_('VLAN group'),
|
||||
fetch_trigger='open'
|
||||
label=_('VLAN group')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=VLANStatusChoices,
|
||||
@@ -455,8 +429,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
vid = forms.IntegerField(
|
||||
required=False,
|
||||
|
||||
@@ -222,7 +222,7 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
|
||||
label='VLAN group',
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
'site': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'vlans': '$vlan'
|
||||
@@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
|
||||
})
|
||||
elif selected_objects:
|
||||
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
else:
|
||||
self.instance.assigned_object = None
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
@@ -578,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm):
|
||||
vrf=self.cleaned_data['ip_vrf'],
|
||||
address=self.cleaned_data['ip_address'],
|
||||
status=self.cleaned_data['ip_status'],
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.save()
|
||||
@@ -590,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm):
|
||||
return instance
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
ip_vrf = self.cleaned_data.get('ip_vrf')
|
||||
ip_address = self.cleaned_data.get('ip_address')
|
||||
ip_status = self.cleaned_data.get('ip_status')
|
||||
@@ -626,8 +630,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
class VLANGroupForm(CustomFieldModelForm):
|
||||
scope_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
widget=StaticSelect
|
||||
required=False
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@@ -32,6 +32,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class GetAvailablePrefixesMixin:
|
||||
|
||||
def get_available_prefixes(self):
|
||||
"""
|
||||
Return all available Prefixes within this aggregate as an IPSet.
|
||||
"""
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||
available_prefixes = prefix - child_prefixes
|
||||
|
||||
return available_prefixes
|
||||
|
||||
def get_first_available_prefix(self):
|
||||
"""
|
||||
Return the first available child prefix within the prefix (or None).
|
||||
"""
|
||||
available_prefixes = self.get_available_prefixes()
|
||||
if not available_prefixes:
|
||||
return None
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RIR(OrganizationalModel):
|
||||
"""
|
||||
@@ -103,14 +125,33 @@ class ASN(PrimaryModel):
|
||||
verbose_name_plural = 'ASNs'
|
||||
|
||||
def __str__(self):
|
||||
return f'AS{self.asn}'
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asn', args=[self.pk])
|
||||
|
||||
@property
|
||||
def asn_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
|
||||
@property
|
||||
def asn_with_asdot(self):
|
||||
"""
|
||||
Return both plain and ASDOT notation, where applicable.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
|
||||
else:
|
||||
return self.asn
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Aggregate(PrimaryModel):
|
||||
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
||||
@@ -207,7 +248,7 @@ class Aggregate(PrimaryModel):
|
||||
"""
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = float(child_prefixes.size) / self.prefix.size * 100
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
@@ -245,7 +286,7 @@ class Role(OrganizationalModel):
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Prefix(PrimaryModel):
|
||||
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
||||
@@ -458,16 +499,6 @@ class Prefix(PrimaryModel):
|
||||
else:
|
||||
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
|
||||
|
||||
def get_available_prefixes(self):
|
||||
"""
|
||||
Return all available Prefixes within this prefix as an IPSet.
|
||||
"""
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||
available_prefixes = prefix - child_prefixes
|
||||
|
||||
return available_prefixes
|
||||
|
||||
def get_available_ips(self):
|
||||
"""
|
||||
Return all available IPs within this prefix as an IPSet.
|
||||
@@ -494,15 +525,6 @@ class Prefix(PrimaryModel):
|
||||
|
||||
return available_ips
|
||||
|
||||
def get_first_available_prefix(self):
|
||||
"""
|
||||
Return the first available child prefix within the prefix (or None).
|
||||
"""
|
||||
available_prefixes = self.get_available_prefixes()
|
||||
if not available_prefixes:
|
||||
return None
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
def get_first_available_ip(self):
|
||||
"""
|
||||
Return the first available IP within the prefix (or None).
|
||||
@@ -526,7 +548,7 @@ class Prefix(PrimaryModel):
|
||||
vrf=self.vrf
|
||||
)
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = float(child_prefixes.size) / self.prefix.size * 100
|
||||
else:
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_ips = netaddr.IPSet(
|
||||
@@ -536,7 +558,7 @@ class Prefix(PrimaryModel):
|
||||
prefix_size = self.prefix.size
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
utilization = int(float(child_ips.size) / prefix_size * 100)
|
||||
utilization = float(child_ips.size) / prefix_size * 100
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
interface_count = tables.Column(
|
||||
verbose_name='Interfaces'
|
||||
member_count = tables.Column(
|
||||
verbose_name='Members'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:fhrpgroup_list'
|
||||
@@ -37,16 +37,16 @@ class FHRPGroupTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = FHRPGroup
|
||||
fields = (
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
|
||||
'tags',
|
||||
'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(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
interface_parent = tables.Column(
|
||||
accessor=tables.A('interface.parent_object'),
|
||||
accessor=tables.A('interface__parent_object'),
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Parent'
|
||||
@@ -60,7 +60,7 @@ class FHRPGroupAssignmentTable(BaseTable):
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FHRPGroupAssignment,
|
||||
buttons=('edit', 'delete', 'foo')
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
||||
@@ -93,7 +93,10 @@ class RIRTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -106,17 +109,34 @@ class ASNTable(BaseTable):
|
||||
asn = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
verbose_name='ASDOT'
|
||||
)
|
||||
site_count = 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()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:asn_list'
|
||||
)
|
||||
|
||||
actions = ButtonsColumn(ASN)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ASN
|
||||
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions')
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'actions', 'created',
|
||||
'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -147,7 +167,10 @@ class AggregateTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
|
||||
|
||||
|
||||
@@ -165,6 +188,11 @@ class RoleTable(BaseTable):
|
||||
url_params={'role_id': 'pk'},
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
iprange_count = LinkedCountColumn(
|
||||
viewname='ipam:iprange_list',
|
||||
url_params={'role_id': 'pk'},
|
||||
verbose_name='IP Ranges'
|
||||
)
|
||||
vlan_count = LinkedCountColumn(
|
||||
viewname='ipam:vlan_list',
|
||||
url_params={'role_id': 'pk'},
|
||||
@@ -177,8 +205,11 @@ class RoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'weight', 'tags',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -264,8 +295,8 @@ class PrefixTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
|
||||
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
|
||||
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||
@@ -306,7 +337,7 @@ class IPRangeTable(BaseTable):
|
||||
model = IPRange
|
||||
fields = (
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
'utilization', 'tags',
|
||||
'utilization', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
@@ -341,7 +372,7 @@ class IPAddressTable(BaseTable):
|
||||
verbose_name='Interface'
|
||||
)
|
||||
assigned_object_parent = tables.Column(
|
||||
accessor='assigned_object.parent_object',
|
||||
accessor='assigned_object__parent_object',
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Device/VM'
|
||||
@@ -364,7 +395,7 @@ class IPAddressTable(BaseTable):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'tags',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
|
||||
|
||||
@@ -31,5 +31,8 @@ class ServiceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Service
|
||||
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
|
||||
@@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -125,7 +128,10 @@ class VLANTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
|
||||
@@ -47,7 +47,8 @@ class VRFTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
@@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
|
||||
@@ -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]}
|
||||
@@ -1337,8 +1369,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]),
|
||||
@@ -1354,6 +1386,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)
|
||||
|
||||
@@ -204,11 +204,11 @@ class TestPrefix(TestCase):
|
||||
IPAddress.objects.bulk_create([
|
||||
IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
|
||||
])
|
||||
self.assertEqual(prefix.get_utilization(), 12) # 12.5% utilization
|
||||
self.assertEqual(prefix.get_utilization(), 32 / 254 * 100) # ~12.5% utilization
|
||||
|
||||
# Create a child range with 32 additional IPs
|
||||
IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
|
||||
self.assertEqual(prefix.get_utilization(), 25) # 25% utilization
|
||||
self.assertEqual(prefix.get_utilization(), 64 / 254 * 100) # ~25% utilization
|
||||
|
||||
#
|
||||
# Uniqueness enforcement tests
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
@@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_aggregate_prefixes(self):
|
||||
rir = RIR.objects.first()
|
||||
aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('192.168.1.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.2.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.3.0/24')),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
self.assertEqual(aggregate.get_child_prefixes().count(), 3)
|
||||
|
||||
url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = Role
|
||||
@@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_prefixes(self):
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('192.168.0.0/16')),
|
||||
Prefix(prefix=IPNetwork('192.168.1.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.2.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.3.0/24')),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_ipranges(self):
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
|
||||
ip_ranges = (
|
||||
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
|
||||
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
|
||||
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
|
||||
)
|
||||
IPRange.objects.bulk_create(ip_ranges)
|
||||
self.assertEqual(prefix.get_child_ranges().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_ipaddresses(self):
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.1/16')),
|
||||
IPAddress(address=IPNetwork('192.168.0.2/16')),
|
||||
IPAddress(address=IPNetwork('192.168.0.3/16')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
self.assertEqual(prefix.get_child_ips().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
@@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_iprange_ipaddresses(self):
|
||||
iprange = IPRange.objects.create(
|
||||
start_address=IPNetwork('192.168.0.1/24'),
|
||||
end_address=IPNetwork('192.168.0.100/24'),
|
||||
size=99
|
||||
)
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.1/24')),
|
||||
IPAddress(address=IPNetwork('192.168.0.2/24')),
|
||||
IPAddress(address=IPNetwork('192.168.0.3/24')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
self.assertEqual(iprange.get_child_ips().count(), 3)
|
||||
|
||||
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPAddress
|
||||
|
||||
@@ -5,18 +5,18 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Device, Interface, Site
|
||||
from dcim.models import Interface, Site
|
||||
from dcim.tables import SiteTable
|
||||
from netbox.views import generic
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
from .constants import *
|
||||
from .models import *
|
||||
from .models import ASN
|
||||
from .utils import add_requested_prefixes, add_available_vlans
|
||||
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
|
||||
|
||||
|
||||
#
|
||||
@@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
return {
|
||||
'bulk_querystring': f'within={instance.prefix}',
|
||||
'active_tab': 'prefixes',
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
|
||||
'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
|
||||
}
|
||||
@@ -339,6 +340,7 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
|
||||
class RoleListView(generic.ObjectListView):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
iprange_count=count_related(IPRange, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
filterset = filtersets.RoleFilterSet
|
||||
@@ -418,7 +420,7 @@ class PrefixView(generic.ObjectView):
|
||||
).filter(
|
||||
prefix__net_contains=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
'site', 'role', 'tenant'
|
||||
)
|
||||
parent_prefix_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
@@ -455,7 +457,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/prefixes.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view')
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
'site', 'vrf', 'vlan', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
# Determine whether to show assigned prefixes, available prefixes, or both
|
||||
@@ -482,7 +486,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_ranges.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ranges().restrict(request.user, 'view')
|
||||
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -500,7 +506,14 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view')
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
show_available = bool(request.GET.get('show_available', 'true') == 'true')
|
||||
if show_available:
|
||||
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -517,7 +530,6 @@ class PrefixEditView(generic.ObjectEditView):
|
||||
|
||||
class PrefixDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix_delete.html'
|
||||
|
||||
|
||||
class PrefixBulkImportView(generic.BulkImportView):
|
||||
@@ -562,7 +574,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/iprange/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view')
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -1024,7 +1038,6 @@ class ServiceListView(generic.ObjectListView):
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
filterset_form = forms.ServiceFilterForm
|
||||
table = tables.ServiceTable
|
||||
action_buttons = ('import', 'export')
|
||||
|
||||
|
||||
class ServiceView(generic.ObjectView):
|
||||
|
||||
@@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
return settings.REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
def configure_groups(self, user, remote_groups):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
|
||||
|
||||
# Assign default groups to the user
|
||||
group_list = []
|
||||
@@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
Return None if ``create_unknown_user`` is ``False`` and a ``User``
|
||||
object with the given username is not found in the database.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
|
||||
logger.debug(
|
||||
f"trying to authenticate {remote_user} with groups {remote_groups}")
|
||||
if not remote_user:
|
||||
@@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
return None
|
||||
|
||||
def _is_superuser(self, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
|
||||
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
|
||||
logger.debug(f"Superuser Groups: {superuser_groups}")
|
||||
superusers = settings.REMOTE_AUTH_SUPERUSERS
|
||||
@@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
return bool(result)
|
||||
|
||||
def _is_staff(self, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
|
||||
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
|
||||
logger.debug(f"Superuser Groups: {staff_groups}")
|
||||
staff_users = settings.REMOTE_AUTH_STAFF_USERS
|
||||
@@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
return bool(result)
|
||||
|
||||
def configure_user(self, request, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
|
||||
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
|
||||
# Assign default groups to the user
|
||||
group_list = []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user