mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-06 00:49:33 +01:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.5
|
||||
placeholder: v3.1.8
|
||||
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.5
|
||||
placeholder: v3.1.8
|
||||
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/).
|
||||
|
||||
@@ -68,7 +68,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.
|
||||
|
||||
@@ -50,7 +50,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 +58,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,86 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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."
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
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
|
||||
),
|
||||
]
|
||||
@@ -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,7 +82,7 @@ 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='ASNs'
|
||||
@@ -98,7 +98,7 @@ class SiteTable(BaseTable):
|
||||
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',
|
||||
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
@@ -138,6 +138,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': "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -317,6 +317,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')
|
||||
)
|
||||
|
||||
@@ -82,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')),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -296,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
|
||||
|
||||
|
||||
@@ -429,6 +429,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 +448,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 +519,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -209,6 +209,10 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable):
|
||||
model = FHRPGroup
|
||||
fields = (
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
|
||||
'tags',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
|
||||
|
||||
@@ -46,7 +46,7 @@ class FHRPGroupTable(BaseTable):
|
||||
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,30 @@ 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='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', 'actions', 'created',
|
||||
'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -147,7 +163,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 +184,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 +201,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 +291,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 +333,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 +368,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 +391,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.5'
|
||||
VERSION = '3.1.8'
|
||||
|
||||
# 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
|
||||
|
||||
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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -597,6 +597,10 @@ span.color-label {
|
||||
box-shadow: $box-shadow-sm;
|
||||
}
|
||||
|
||||
.badge a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
--nbx-sidebar-bg: #{$gray-200};
|
||||
--nbx-sidebar-scroll: #{$gray-500};
|
||||
--nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)};
|
||||
--nbx-sidebar-link-active-bg: #{$blue-100};
|
||||
--nbx-sidebar-link-active-bg: #9cc8f8;
|
||||
--nbx-sidebar-title-color: #{$text-muted};
|
||||
--nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
|
||||
--nbx-breadcrumb-bg: #{$light};
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<th scope="row">Location</th>
|
||||
<tr>
|
||||
<th scope="row">Location</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
@@ -129,7 +130,7 @@
|
||||
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% badge vc_member.vc_position %}
|
||||
{% badge vc_member.vc_position show_empty=True %}
|
||||
</td>
|
||||
<td>
|
||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
@@ -188,7 +189,7 @@
|
||||
{% if object.primary_ip4 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
|
||||
{% if object.primary_ip4.nat_inside %}
|
||||
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }})">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
||||
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
||||
{% elif object.primary_ip4.nat_outside %}
|
||||
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
|
||||
{% endif %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -80,5 +80,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -42,5 +42,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
Change log retention: {% if config.CHANGELOG_RETENTION %}{{ config.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
|
||||
{% if object.related_object.get_absolute_url %}
|
||||
{% if object.related_object and object.related_object.get_absolute_url %}
|
||||
<li class="breadcrumb-item"><a href="{{ object.related_object.get_absolute_url }}changelog/">{{ object.related_object }}</a></li>
|
||||
{% elif object.changed_object.get_absolute_url %}
|
||||
{% elif object.changed_object and object.changed_object.get_absolute_url %}
|
||||
<li class="breadcrumb-item"><a href="{{ object.changed_object.get_absolute_url }}changelog/">{{ object.changed_object }}</a></li>
|
||||
{% elif object.changed_object %}
|
||||
{% elif object.changed_object and object.changed_object.get_display %}
|
||||
<li class="breadcrumb-item">{{ object.changed_object }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item">{{ object.object_repr }}</li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +38,11 @@
|
||||
<tr>
|
||||
<th scope="row">User</th>
|
||||
<td>
|
||||
{{ object.user|default:object.user_name }}
|
||||
{% if object.user.get_full_name %}
|
||||
{{ object.user.get_full_name }} ({{ object.user_name }})
|
||||
{% else %}
|
||||
{{ object.user_name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -54,7 +60,7 @@
|
||||
<tr>
|
||||
<th scope="row">Object</th>
|
||||
<td>
|
||||
{% if object.changed_object.get_absolute_url %}
|
||||
{% if object.changed_object and object.changed_object.get_absolute_url %}
|
||||
<a href="{{ object.changed_object.get_absolute_url }}">{{ object.changed_object }}</a>
|
||||
{% else %}
|
||||
{{ object.object_repr }}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
{% block title %}Change Log{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<div class="text-muted">
|
||||
Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
</div>
|
||||
{% block content-wrapper %}
|
||||
{{ block.super }}
|
||||
<div class="text-muted px-3">
|
||||
Change log retention: {% if config.CHANGELOG_RETENTION %}{{ config.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,17 +24,17 @@
|
||||
{% else %}
|
||||
{# List all non-customfield filters as declared in the form class #}
|
||||
{% for field in filter_form.visible_fields %}
|
||||
{% if not filter_form.custom_field_filters or field.name not in filter_form.custom_field_filters %}
|
||||
{% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
|
||||
<div class="col col-12">
|
||||
{% render_field field %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if filter_form.custom_field_filters %}
|
||||
{% if filter_form.custom_fields %}
|
||||
{# List all custom field filters #}
|
||||
<hr class="card-divider mt-0" />
|
||||
{% for name in filter_form.custom_field_filters %}
|
||||
{% for name in filter_form.custom_fields %}
|
||||
<div class="col col-12">
|
||||
{% with field=filter_form|get_item:name %}
|
||||
{% render_field field %}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
|
||||
<a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}?next={{ request.path }}">
|
||||
<i class="mdi mdi-login-variant"></i> Log In
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<td>AS Number</td>
|
||||
<td>{{ object.asn }}</td>
|
||||
<td>{{ object.asn_with_asdot }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RIR</td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user