mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-13 22:03:32 +01:00
Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6807db4967 | ||
|
|
b0ea416d6d | ||
|
|
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 | ||
|
|
4863591bc8 | ||
|
|
c489501441 | ||
|
|
1a7438acfd | ||
|
|
b1de85a44f | ||
|
|
4913d7ee39 | ||
|
|
2503a3e3ca | ||
|
|
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 |
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.4
|
||||
placeholder: v3.1.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.4
|
||||
placeholder: v3.1.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -38,6 +38,19 @@ 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: |
|
||||
@@ -45,7 +58,6 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
pip install pycodestyle coverage
|
||||
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*
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
</div>
|
||||
|
||||
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
|
||||
|
||||

|
||||
|
||||
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
|
||||
@@ -68,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,24 +114,30 @@ 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.
|
||||
|
||||
```no-highlight
|
||||
$ python netbox/manage.py test
|
||||
$ 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,131 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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,7 +115,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
@@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@@ -69,7 +90,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 +113,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,6 +140,7 @@ class CircuitTable(BaseTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
@@ -128,7 +150,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', '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',
|
||||
]
|
||||
|
||||
@@ -621,7 +621,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
|
||||
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)
|
||||
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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
@@ -237,7 +237,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -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():
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -152,7 +152,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Location
|
||||
field_groups = [
|
||||
['q'],
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id', 'parent_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
@@ -578,7 +578,7 @@ 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(
|
||||
@@ -603,6 +603,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'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',
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=add_blank_choice(CableTypeChoices),
|
||||
required=False,
|
||||
@@ -616,15 +626,12 @@ 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')
|
||||
length = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
length_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
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
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -45,7 +45,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(
|
||||
@@ -56,7 +56,7 @@ class CableTable(BaseTable):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags',
|
||||
'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',
|
||||
@@ -204,7 +204,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', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
@@ -260,7 +261,7 @@ class CableTerminationTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
cable_color = ColorColumn(
|
||||
accessor='cable.color',
|
||||
accessor='cable__color',
|
||||
orderable=False,
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
@@ -275,7 +276,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 +298,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 +342,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 +387,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 +438,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 +516,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 +587,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 +638,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')
|
||||
|
||||
@@ -689,7 +690,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', 'installed_device', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
@@ -736,7 +741,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 +793,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')
|
||||
|
||||
@@ -50,7 +50,7 @@ class ManufacturerTable(BaseTable):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
@@ -67,6 +67,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 +87,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',
|
||||
|
||||
@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
@@ -72,7 +72,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')
|
||||
|
||||
|
||||
@@ -87,8 +90,9 @@ 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', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
@@ -127,7 +131,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',
|
||||
|
||||
@@ -36,7 +36,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', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -61,7 +61,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', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -82,9 +82,13 @@ 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()
|
||||
@@ -96,9 +100,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', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
@@ -138,6 +142,6 @@ class LocationTable(BaseTable):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
|
||||
'actions',
|
||||
'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()
|
||||
|
||||
@@ -2035,8 +2035,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
@@ -211,9 +211,8 @@ class ConfigContextFilterForm(FilterForm):
|
||||
required=False,
|
||||
label=_('Tenant')
|
||||
)
|
||||
tag = DynamicModelMultipleChoiceField(
|
||||
tag_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
label=_('Tags')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -290,12 +296,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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -122,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])
|
||||
@@ -138,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)
|
||||
@@ -597,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()
|
||||
@@ -607,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)
|
||||
@@ -628,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)
|
||||
@@ -640,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)
|
||||
|
||||
@@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
|
||||
)
|
||||
objectchanges_table = tables.ObjectChangeTable(
|
||||
data=objectchanges,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
paginate_table(objectchanges_table, request)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -189,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):
|
||||
@@ -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():
|
||||
|
||||
@@ -580,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()
|
||||
@@ -592,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')
|
||||
@@ -628,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(),
|
||||
|
||||
@@ -125,11 +125,30 @@ 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(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -340,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
|
||||
@@ -505,9 +506,7 @@ 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').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
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')
|
||||
@@ -531,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):
|
||||
@@ -1040,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):
|
||||
|
||||
@@ -20,19 +20,28 @@ PARAMS = (
|
||||
name='BANNER_LOGIN',
|
||||
label='Login banner',
|
||||
default='',
|
||||
description="Additional content to display on the login page"
|
||||
description="Additional content to display on the login page",
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(),
|
||||
},
|
||||
),
|
||||
ConfigParam(
|
||||
name='BANNER_TOP',
|
||||
label='Top banner',
|
||||
default='',
|
||||
description="Additional content to display at the top of every page"
|
||||
description="Additional content to display at the top of every page",
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(),
|
||||
},
|
||||
),
|
||||
ConfigParam(
|
||||
name='BANNER_BOTTOM',
|
||||
label='Bottom banner',
|
||||
default='',
|
||||
description="Additional content to display at the bottom of every page"
|
||||
description="Additional content to display at the bottom of every page",
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(),
|
||||
},
|
||||
),
|
||||
|
||||
# IPAM
|
||||
|
||||
@@ -12,12 +12,14 @@ from dcim.tables import (
|
||||
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
|
||||
VirtualChassisTable,
|
||||
)
|
||||
from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||
from tenancy.filtersets import TenantFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.tables import TenantTable
|
||||
from ipam.filtersets import (
|
||||
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
|
||||
)
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
|
||||
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||
from tenancy.tables import ContactTable, TenantTable
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
@@ -170,6 +172,12 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
}),
|
||||
('asn', {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
|
||||
'filterset': ASNFilterSet,
|
||||
'table': ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
}),
|
||||
# Tenancy
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
@@ -177,4 +185,10 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
('contact', {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
|
||||
'filterset': ContactFilterSet,
|
||||
'table': ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -19,7 +19,7 @@ from netbox.config import PARAMS
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.1.4'
|
||||
VERSION = '3.1.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
@@ -133,7 +133,7 @@ class HomeView(View):
|
||||
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'user', 'changed_object_type'
|
||||
)[:10]
|
||||
changelog_table = ObjectChangeTable(changelog)
|
||||
changelog_table = ObjectChangeTable(changelog, user=request.user)
|
||||
|
||||
# Check whether a new release is available. (Only for staff/superusers.)
|
||||
new_release = None
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import is_safe_url
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
obj = self.get_object(kwargs)
|
||||
form = ConfirmationForm(initial=request.GET)
|
||||
|
||||
# If this is an HTMX request, return only the rendered deletion form as modal content
|
||||
if is_htmx(request):
|
||||
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
|
||||
form_url = reverse(viewname, kwargs={'pk': obj.pk})
|
||||
return render(request, 'htmx/delete_form.html', {
|
||||
'object': obj,
|
||||
'object_type': self.queryset.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'form_url': form_url,
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj': obj,
|
||||
'object': obj,
|
||||
'object_type': self.queryset.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'obj_type': self.queryset.model._meta.verbose_name,
|
||||
'return_url': self.get_return_url(request, obj),
|
||||
})
|
||||
|
||||
@@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj': obj,
|
||||
'object': obj,
|
||||
'object_type': self.queryset.model._meta.verbose_name,
|
||||
'form': form,
|
||||
'obj_type': self.queryset.model._meta.verbose_name,
|
||||
'return_url': self.get_return_url(request, obj),
|
||||
})
|
||||
|
||||
|
||||
2
netbox/project-static/dist/config.js.map
vendored
2
netbox/project-static/dist/config.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/lldp.js.map
vendored
2
netbox/project-static/dist/lldp.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
16
netbox/project-static/dist/netbox.js
vendored
16
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/status.js
vendored
2
netbox/project-static/dist/status.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/status.js.map
vendored
2
netbox/project-static/dist/status.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,16 @@ type ShowHideMap = {
|
||||
*
|
||||
* @example vlangroup_edit
|
||||
*/
|
||||
[view: string]: {
|
||||
[view: string]: string;
|
||||
};
|
||||
|
||||
type ShowHideLayout = {
|
||||
/**
|
||||
* Name of layout config
|
||||
*
|
||||
* @example vlangroup
|
||||
*/
|
||||
[config: string]: {
|
||||
/**
|
||||
* Default layout.
|
||||
*/
|
||||
@@ -19,15 +28,15 @@ type ShowHideMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of scope names to arrays of object types whose fields should be hidden or shown when
|
||||
* Mapping of layout names to arrays of object types whose fields should be hidden or shown when
|
||||
* the scope type (key) is selected.
|
||||
*
|
||||
* For example, if `region` is the scope type, the fields with IDs listed in
|
||||
* showHideMap.region.hide should be hidden, and the fields with IDs listed in
|
||||
* showHideMap.region.show should be shown.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_edit: {
|
||||
const showHideLayout: ShowHideLayout = {
|
||||
vlangroup: {
|
||||
region: {
|
||||
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region'],
|
||||
@@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of view names to layout configurations
|
||||
*
|
||||
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_add: 'vlangroup',
|
||||
vlangroup_edit: 'vlangroup',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle visibility of a given element's parent.
|
||||
* @param query CSS Query.
|
||||
@@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
|
||||
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
|
||||
// Scope type's innerText looks something like `DCIM > region`.
|
||||
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
|
||||
const layoutConfig = showHideMap[view];
|
||||
|
||||
for (const [scope, fields] of Object.entries(showHideMap[view])) {
|
||||
for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
|
||||
// If the scope type ends with the specified scope, toggle its field visibility according to
|
||||
// the show/hide values.
|
||||
if (scopeType.endsWith(scope)) {
|
||||
@@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
|
||||
break;
|
||||
} else {
|
||||
// Otherwise, hide all fields.
|
||||
for (const field of showHideMap[view].default.hide) {
|
||||
for (const field of showHideLayout[layoutConfig].default.hide) {
|
||||
toggleParentVisibility(`#${field}`, 'hide');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
|
||||
import { isStaticParams, isOption } from './types';
|
||||
import {
|
||||
hasMore,
|
||||
isTruthy,
|
||||
hasError,
|
||||
getElement,
|
||||
isTruthy,
|
||||
getApiData,
|
||||
getElement,
|
||||
isApiError,
|
||||
replaceAll,
|
||||
createElement,
|
||||
uniqueByProperty,
|
||||
findFirstAdjacent,
|
||||
@@ -461,7 +462,7 @@ export class APISelect {
|
||||
// Set any primitive k/v pairs as data attributes on each option.
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||
const key = k.replaceAll('_', '-');
|
||||
const key = replaceAll(k, '_', '-');
|
||||
data[key] = String(v);
|
||||
}
|
||||
// Set option to disabled if the result contains a matching key and is truthy.
|
||||
@@ -659,7 +660,7 @@ export class APISelect {
|
||||
for (const [key, value] of this.pathValues.entries()) {
|
||||
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
||||
if (isTruthy(value)) {
|
||||
url = url.replaceAll(result[1], value.toString());
|
||||
url = replaceAll(url, result[1], value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -741,7 +742,7 @@ export class APISelect {
|
||||
* @param id DOM ID of the other element.
|
||||
*/
|
||||
private updatePathValues(id: string): void {
|
||||
const key = id.replaceAll(/^id_/gi, '');
|
||||
const key = replaceAll(id, /^id_/i, '');
|
||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
||||
if (element !== null) {
|
||||
// If this element's URL contains Django template tags ({{), replace the template tag
|
||||
@@ -919,16 +920,18 @@ export class APISelect {
|
||||
style.setAttribute('data-netbox', id);
|
||||
|
||||
// Scope the CSS to apply both the list item and the selected item.
|
||||
style.innerHTML = `
|
||||
style.innerHTML = replaceAll(
|
||||
`
|
||||
div.ss-values div.ss-value[data-id="${id}"],
|
||||
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
||||
{
|
||||
background-color: ${bg} !important;
|
||||
color: ${fg} !important;
|
||||
}
|
||||
`
|
||||
.replaceAll('\n', '')
|
||||
.trim();
|
||||
`,
|
||||
'\n',
|
||||
'',
|
||||
).trim();
|
||||
|
||||
// Add the style element to the DOM.
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -11,15 +11,6 @@ function saveTableConfig(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all selected columns, which reverts the user's preferences to the default column set.
|
||||
*/
|
||||
function resetTableConfig(): void {
|
||||
for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
|
||||
element.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add columns to the table config select element.
|
||||
*/
|
||||
@@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
|
||||
/**
|
||||
* Submit form configuration to the NetBox API.
|
||||
*/
|
||||
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
|
||||
async function submitFormConfig(
|
||||
url: string,
|
||||
formConfig: Dict<Dict>,
|
||||
): Promise<APIResponse<APIUserConfig>> {
|
||||
return await apiPatch<APIUserConfig>(url, formConfig);
|
||||
}
|
||||
|
||||
@@ -70,25 +64,46 @@ function handleSubmit(event: Event): void {
|
||||
const url = element.getAttribute('data-url');
|
||||
if (url == null) {
|
||||
const toast = createToast(
|
||||
'danger',
|
||||
'Error Updating Table Configuration',
|
||||
'No API path defined for configuration form.'
|
||||
'danger',
|
||||
'Error Updating Table Configuration',
|
||||
'No API path defined for configuration form.',
|
||||
);
|
||||
toast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if the form action is to reset the table config.
|
||||
const reset = document.activeElement?.getAttribute('value') === 'Reset';
|
||||
|
||||
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
||||
// ['tables', 'DevicePowerOutletTable']
|
||||
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
||||
|
||||
if (reset) {
|
||||
// If we're resetting the table config, create an empty object for this table. E.g.
|
||||
// tables.PlatformTable becomes {tables: PlatformTable: {}}
|
||||
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
|
||||
|
||||
// Submit the reset for configuration to the API.
|
||||
submitFormConfig(url, data).then(res => {
|
||||
if (hasError(res)) {
|
||||
const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
|
||||
toast.show();
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all the selected options from any select element in the form.
|
||||
const options = getSelectedOptions(element);
|
||||
const options = getSelectedOptions(element, 'select[name=columns]');
|
||||
|
||||
// Create an object mapping the select element's name to all selected options for that element.
|
||||
const formData: Dict<Dict<string>> = Object.assign(
|
||||
{},
|
||||
...options.map(opt => ({ [opt.name]: opt.options })),
|
||||
);
|
||||
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
||||
// ['tables', 'DevicePowerOutletTable']
|
||||
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
||||
|
||||
// Create an object mapping the configuration path to the select element names, which contain the
|
||||
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
|
||||
@@ -112,9 +127,6 @@ export function initTableConfig(): void {
|
||||
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
||||
element.addEventListener('click', saveTableConfig);
|
||||
}
|
||||
for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
|
||||
element.addEventListener('click', resetTableConfig);
|
||||
}
|
||||
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
||||
element.addEventListener('click', addColumns);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getElements, findFirstAdjacent } from '../util';
|
||||
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||
|
||||
type InterfaceState = 'enabled' | 'disabled';
|
||||
type ShowHide = 'show' | 'hide';
|
||||
@@ -105,9 +105,9 @@ class ButtonState {
|
||||
*/
|
||||
private toggleButton(): void {
|
||||
if (this.buttonState === 'show') {
|
||||
this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide');
|
||||
this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
|
||||
} else if (this.buttonState === 'hide') {
|
||||
this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show');
|
||||
this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
|
||||
* Iterate through a select element's options and return an array of options that are selected.
|
||||
*
|
||||
* @param base Select element.
|
||||
* @param selector Optionally specify a selector. 'select' by default.
|
||||
* @returns Array of selected options.
|
||||
*/
|
||||
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
|
||||
export function getSelectedOptions<E extends HTMLElement>(
|
||||
base: E,
|
||||
selector: string = 'select',
|
||||
): SelectedOption[] {
|
||||
let selected = [] as SelectedOption[];
|
||||
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
|
||||
for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
|
||||
if (element !== null) {
|
||||
const select = { name: element.name, options: [] } as SelectedOption;
|
||||
for (const option of element.options) {
|
||||
@@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
|
||||
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
||||
if (element !== null) {
|
||||
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
||||
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
|
||||
yield replaceAll(element.innerText, '[\n\r]', '').trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
|
||||
}
|
||||
return Array.from(baseMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of a pattern with a replacement string.
|
||||
*
|
||||
* This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
|
||||
* introduced in ES2021.
|
||||
*
|
||||
* @param input string to be processed.
|
||||
* @param pattern regex pattern string or RegExp object to search for.
|
||||
* @param replacement replacement substring with which `pattern` matches will be replaced.
|
||||
* @returns processed version of `input`.
|
||||
*/
|
||||
export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
|
||||
// Ensure input is a string.
|
||||
if (typeof input !== 'string') {
|
||||
throw new TypeError("replaceAll 'input' argument must be a string");
|
||||
}
|
||||
// Ensure pattern is a string or RegExp.
|
||||
if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
|
||||
throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
|
||||
}
|
||||
// Ensure replacement is able to be stringified.
|
||||
switch (typeof replacement) {
|
||||
case 'boolean':
|
||||
replacement = String(replacement);
|
||||
break;
|
||||
case 'number':
|
||||
replacement = String(replacement);
|
||||
break;
|
||||
case 'string':
|
||||
break;
|
||||
default:
|
||||
throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
|
||||
}
|
||||
|
||||
if (pattern instanceof RegExp) {
|
||||
// Add global flag to existing RegExp object and deduplicate
|
||||
const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
|
||||
pattern = new RegExp(pattern.source, flags);
|
||||
} else {
|
||||
// Create a RegExp object with the global flag set.
|
||||
pattern = new RegExp(pattern, 'g');
|
||||
}
|
||||
|
||||
return input.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ nav.search {
|
||||
// Don't overtake dropdowns
|
||||
z-index: 999;
|
||||
justify-content: center;
|
||||
background-color: var(--nbx-body-bg);
|
||||
background-color: $navbar-light-color;
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
@@ -452,8 +452,8 @@ main.login-container {
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: $tab-content-bg;
|
||||
padding: 0;
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -517,6 +517,10 @@ h6.accordion-item-title {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
@@ -554,6 +558,7 @@ div.content-container {
|
||||
}
|
||||
|
||||
div.content {
|
||||
background-color: $tab-content-bg;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -592,6 +597,10 @@ span.color-label {
|
||||
box-shadow: $box-shadow-sm;
|
||||
}
|
||||
|
||||
.badge a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -898,6 +907,7 @@ div.card-overlay {
|
||||
|
||||
// Tabbed content
|
||||
.nav-tabs {
|
||||
background-color: $body-bg;
|
||||
.nav-link {
|
||||
&:hover {
|
||||
// Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
|
||||
@@ -919,14 +929,6 @@ div.card-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $spacer;
|
||||
background-color: $tab-content-bg;
|
||||
border-bottom: 1px solid $nav-tabs-border-color;
|
||||
|
||||
// Remove background and border when printing.
|
||||
@media print {
|
||||
background-color: var(--nbx-body-bg) !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Override masonry-layout styles when printing.
|
||||
|
||||
@@ -223,11 +223,6 @@
|
||||
font-weight: $font-weight-bold;
|
||||
color: var(--nbx-sidenav-parent-color);
|
||||
|
||||
&.active {
|
||||
color: $accordion-button-active-color;
|
||||
background: $accordion-button-active-bg;
|
||||
}
|
||||
|
||||
&:after {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
@@ -284,7 +279,7 @@
|
||||
font-size: $font-size-sm;
|
||||
color: var(--nbx-sidenav-link-color);
|
||||
white-space: nowrap;
|
||||
transition: $transition-100ms-ease-in-out;
|
||||
transition-duration: 0ms;
|
||||
|
||||
&.active {
|
||||
background-color: var(--nbx-sidebar-link-active-bg);
|
||||
|
||||
@@ -33,95 +33,6 @@ $darkest: #171b1d;
|
||||
|
||||
@import '../node_modules/bootstrap/scss/variables';
|
||||
|
||||
// Make color palette colors available as theme colors.
|
||||
// For example, you could use `.bg-red-100`, if needed.
|
||||
$theme-color-addons: (
|
||||
'darker': $darker,
|
||||
'darkest': $darkest,
|
||||
'gray': $gray-400,
|
||||
'gray-100': $gray-100,
|
||||
'gray-200': $gray-200,
|
||||
'gray-300': $gray-300,
|
||||
'gray-400': $gray-400,
|
||||
'gray-500': $gray-500,
|
||||
'gray-600': $gray-600,
|
||||
'gray-700': $gray-700,
|
||||
'gray-800': $gray-800,
|
||||
'gray-900': $gray-900,
|
||||
'red-100': $red-100,
|
||||
'red-200': $red-200,
|
||||
'red-300': $red-300,
|
||||
'red-400': $red-400,
|
||||
'red-500': $red-500,
|
||||
'red-600': $red-600,
|
||||
'red-700': $red-700,
|
||||
'red-800': $red-800,
|
||||
'red-900': $red-900,
|
||||
'yellow-100': $yellow-100,
|
||||
'yellow-200': $yellow-200,
|
||||
'yellow-300': $yellow-300,
|
||||
'yellow-400': $yellow-400,
|
||||
'yellow-500': $yellow-500,
|
||||
'yellow-600': $yellow-600,
|
||||
'yellow-700': $yellow-700,
|
||||
'yellow-800': $yellow-800,
|
||||
'yellow-900': $yellow-900,
|
||||
'green-100': $green-100,
|
||||
'green-200': $green-200,
|
||||
'green-300': $green-300,
|
||||
'green-400': $green-400,
|
||||
'green-500': $green-500,
|
||||
'green-600': $green-600,
|
||||
'green-700': $green-700,
|
||||
'green-800': $green-800,
|
||||
'green-900': $green-900,
|
||||
'blue-100': $blue-100,
|
||||
'blue-200': $blue-200,
|
||||
'blue-300': $blue-300,
|
||||
'blue-400': $blue-400,
|
||||
'blue-500': $blue-500,
|
||||
'blue-600': $blue-600,
|
||||
'blue-700': $blue-700,
|
||||
'blue-800': $blue-800,
|
||||
'blue-900': $blue-900,
|
||||
'cyan-100': $cyan-100,
|
||||
'cyan-200': $cyan-200,
|
||||
'cyan-300': $cyan-300,
|
||||
'cyan-400': $cyan-400,
|
||||
'cyan-500': $cyan-500,
|
||||
'cyan-600': $cyan-600,
|
||||
'cyan-700': $cyan-700,
|
||||
'cyan-800': $cyan-800,
|
||||
'cyan-900': $cyan-900,
|
||||
'indigo-100': $indigo-100,
|
||||
'indigo-200': $indigo-200,
|
||||
'indigo-300': $indigo-300,
|
||||
'indigo-400': $indigo-400,
|
||||
'indigo-500': $indigo-500,
|
||||
'indigo-600': $indigo-600,
|
||||
'indigo-700': $indigo-700,
|
||||
'indigo-800': $indigo-800,
|
||||
'indigo-900': $indigo-900,
|
||||
'purple-100': $purple-100,
|
||||
'purple-200': $purple-200,
|
||||
'purple-300': $purple-300,
|
||||
'purple-400': $purple-400,
|
||||
'purple-500': $purple-500,
|
||||
'purple-600': $purple-600,
|
||||
'purple-700': $purple-700,
|
||||
'purple-800': $purple-800,
|
||||
'purple-900': $purple-900,
|
||||
'pink-100': $pink-100,
|
||||
'pink-200': $pink-200,
|
||||
'pink-300': $pink-300,
|
||||
'pink-400': $pink-400,
|
||||
'pink-500': $pink-500,
|
||||
'pink-600': $pink-600,
|
||||
'pink-700': $pink-700,
|
||||
'pink-800': $pink-800,
|
||||
'pink-900': $pink-900,
|
||||
);
|
||||
|
||||
// This is the same value as the default from Bootstrap, but it needs to be in scope prior to
|
||||
// importing _variables.scss from Bootstrap.
|
||||
$btn-close-width: 1em;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@use 'sass:map';
|
||||
@import './theme-base';
|
||||
|
||||
// Theme colors (BS5 classes)
|
||||
$primary: $blue-300;
|
||||
$secondary: $gray-500;
|
||||
$success: $green-300;
|
||||
@@ -13,6 +14,7 @@ $light: $gray-300;
|
||||
$dark: $gray-500;
|
||||
|
||||
$theme-colors: (
|
||||
// BS5 theme colors
|
||||
'primary': $primary,
|
||||
'secondary': $secondary,
|
||||
'success': $success,
|
||||
@@ -21,18 +23,22 @@ $theme-colors: (
|
||||
'danger': $danger,
|
||||
'light': $light,
|
||||
'dark': $dark,
|
||||
'red': $red-300,
|
||||
'yellow': $yellow-300,
|
||||
'green': $green-300,
|
||||
// General-purpose palette
|
||||
'blue': $blue-300,
|
||||
'cyan': $cyan-300,
|
||||
'indigo': $indigo-300,
|
||||
'purple': $purple-300,
|
||||
'pink': $pink-300,
|
||||
'red': $red-300,
|
||||
'orange': $orange-300,
|
||||
'yellow': $yellow-300,
|
||||
'green': $green-300,
|
||||
'teal': $teal-300,
|
||||
'cyan': $cyan-300,
|
||||
'gray': $gray-300,
|
||||
'black': $black,
|
||||
'white': $white
|
||||
);
|
||||
|
||||
$theme-colors: map-merge($theme-colors, $theme-color-addons);
|
||||
|
||||
// Gradient
|
||||
$gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0));
|
||||
|
||||
@@ -139,7 +145,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
|
||||
$nav-pills-link-active-color: $component-active-color;
|
||||
$nav-pills-link-active-bg: $component-active-bg;
|
||||
|
||||
$navbar-light-color: $gray-500;
|
||||
$navbar-light-color: $navbar-dark-color;
|
||||
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
|
||||
$navbar-light-toggler-border-color: $gray-700;
|
||||
|
||||
|
||||
@@ -2,28 +2,47 @@
|
||||
|
||||
@import './theme-base.scss';
|
||||
|
||||
$input-border-color: $gray-200;
|
||||
// Theme colors (BS5 classes)
|
||||
$primary: #337ab7;
|
||||
$secondary: $gray-600;
|
||||
$success: $green-500;
|
||||
$info: #54d6f0;
|
||||
$warning: $yellow-500;
|
||||
$danger: $red-500;
|
||||
$light: $gray-200;
|
||||
$dark: $gray-800;
|
||||
|
||||
$theme-colors: map-merge(
|
||||
$theme-colors,
|
||||
(
|
||||
'primary': #337ab7,
|
||||
'info': #54d6f0,
|
||||
'red': $red-500,
|
||||
'yellow': $yellow-500,
|
||||
'green': $green-500,
|
||||
'blue': $blue-500,
|
||||
'cyan': $cyan-500,
|
||||
'indigo': $indigo-500,
|
||||
'purple': $purple-500,
|
||||
'pink': $pink-500,
|
||||
)
|
||||
$theme-colors: (
|
||||
// BS5 theme colors
|
||||
'primary': $primary,
|
||||
'secondary': $secondary,
|
||||
'success': $success,
|
||||
'info': $info,
|
||||
'warning': $warning,
|
||||
'danger': $danger,
|
||||
'light': $light,
|
||||
'dark': $dark,
|
||||
|
||||
// General-purpose palette
|
||||
'blue': $blue-500,
|
||||
'indigo': $indigo-500,
|
||||
'purple': $purple-500,
|
||||
'pink': $pink-500,
|
||||
'red': $red-500,
|
||||
'orange': $orange-500,
|
||||
'yellow': $yellow-500,
|
||||
'green': $green-500,
|
||||
'teal': $teal-500,
|
||||
'cyan': $cyan-500,
|
||||
'gray': $gray-500,
|
||||
'black': $black,
|
||||
'white': $white,
|
||||
);
|
||||
|
||||
$theme-colors: map-merge($theme-colors, $theme-color-addons);
|
||||
|
||||
$light: $gray-200;
|
||||
|
||||
$navbar-light-color: $gray-100;
|
||||
|
||||
$card-cap-color: $gray-800;
|
||||
|
||||
$accordion-bg: transparent;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user