mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-12 11:57:43 +01:00
Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f8a94399 | ||
|
|
631de20a8d | ||
|
|
0f5fe746e0 | ||
|
|
a7fc8621a8 | ||
|
|
e575279738 | ||
|
|
796f7258cc | ||
|
|
8126087b3e | ||
|
|
780459d2bf | ||
|
|
99a01207bc | ||
|
|
6d6457ad18 | ||
|
|
35f3a42e7f | ||
|
|
a84ae88214 | ||
|
|
58e4d08bb0 | ||
|
|
91e8f57afb | ||
|
|
e3d0628a06 | ||
|
|
9fca9ca7ec | ||
|
|
2d09a40663 | ||
|
|
1eaf55c555 | ||
|
|
db535e6453 | ||
|
|
dadec9d3cb | ||
|
|
ff780177d0 | ||
|
|
b7e2ea1ca5 | ||
|
|
894665b067 | ||
|
|
48b7294ff1 | ||
|
|
cde8ff282d | ||
|
|
0b44a595e2 | ||
|
|
37781bd208 | ||
|
|
e0344e9251 | ||
|
|
a1808a54a4 | ||
|
|
1cef513f6c | ||
|
|
57759aa4a3 | ||
|
|
d50148fab7 | ||
|
|
271c2ea3e3 | ||
|
|
20a6f6ac79 | ||
|
|
8924d5fa05 | ||
|
|
26637d934b | ||
|
|
dde4495e20 | ||
|
|
1278429518 | ||
|
|
421f5a03aa | ||
|
|
a433d5d59d | ||
|
|
934493bf5f | ||
|
|
a5820e27a6 | ||
|
|
d312fe7c2b | ||
|
|
124fc73386 | ||
|
|
c78e7c14d3 | ||
|
|
30a6dc2f64 | ||
|
|
6ceb78fd4c | ||
|
|
e09ab79a1a | ||
|
|
b6587c00ce | ||
|
|
f45e64c756 | ||
|
|
ae46cd33b6 | ||
|
|
41efad4056 | ||
|
|
5f89226cd7 | ||
|
|
197dfca5b2 | ||
|
|
e6980626d8 | ||
|
|
22980cea7b | ||
|
|
f64987d0c4 | ||
|
|
0da04232f3 | ||
|
|
9a0bb14e76 | ||
|
|
900825a2af | ||
|
|
52de50aa64 | ||
|
|
1541060091 | ||
|
|
50bc0caccf | ||
|
|
8f5b14ec84 | ||
|
|
da37db1ea9 | ||
|
|
5abde866f1 | ||
|
|
32eed72d2b | ||
|
|
585b5a221d | ||
|
|
db52fe475a | ||
|
|
c5db99f383 | ||
|
|
fd6d3205d0 | ||
|
|
9548cf32ff | ||
|
|
bdbfff911b | ||
|
|
a143eca57d | ||
|
|
3edff89a4d | ||
|
|
1add5accf2 | ||
|
|
faba6c9bdc | ||
|
|
4eb7cd06b4 | ||
|
|
342f1d31be | ||
|
|
b779bbfc9d | ||
|
|
ef6576bdd6 | ||
|
|
27dab262de | ||
|
|
412c1df15a | ||
|
|
73af3ba095 | ||
|
|
21b7564976 | ||
|
|
bf22b820bf | ||
|
|
8cd24b1a67 | ||
|
|
1fdc7a9163 | ||
|
|
6807db4967 | ||
|
|
b0ea416d6d | ||
|
|
c515218760 | ||
|
|
8053ea0a22 | ||
|
|
a5603c9953 | ||
|
|
bffe63a233 | ||
|
|
2cfbfe473e | ||
|
|
3c78c100b5 | ||
|
|
2451b0a5b1 | ||
|
|
85e9438ff7 | ||
|
|
81610ba86e | ||
|
|
6423b386d2 | ||
|
|
5c48d116eb | ||
|
|
90257e9dee | ||
|
|
3436905744 | ||
|
|
e3258bcf5a | ||
|
|
2b6e0405a5 | ||
|
|
7f752d9102 | ||
|
|
df430394b0 | ||
|
|
1ab51ca04e | ||
|
|
cb0386779c | ||
|
|
28de330b50 | ||
|
|
06cb7f35f1 | ||
|
|
796c5d785e | ||
|
|
c88db77814 | ||
|
|
6fe0f4cd7d | ||
|
|
3bf90c3c38 | ||
|
|
992f3535b7 | ||
|
|
06eacb5a5c | ||
|
|
c0152ce52f | ||
|
|
5a60224d77 | ||
|
|
c137fa2022 | ||
|
|
879d01a750 | ||
|
|
6db878743c | ||
|
|
08b90090f5 | ||
|
|
42466d5fc4 | ||
|
|
36d6dd1ca9 | ||
|
|
4863591bc8 | ||
|
|
c489501441 | ||
|
|
1a7438acfd | ||
|
|
b1de85a44f | ||
|
|
4913d7ee39 | ||
|
|
2503a3e3ca | ||
|
|
538984c6d2 | ||
|
|
90ee689d5a | ||
|
|
b343035060 | ||
|
|
6bbf168cec | ||
|
|
b5e4fdc3d8 | ||
|
|
90f91eeea4 | ||
|
|
ae0ae5fd4e | ||
|
|
5b7486cff8 | ||
|
|
14240318f1 | ||
|
|
18eb9ffae6 | ||
|
|
c0a62793c4 | ||
|
|
dd848d754f | ||
|
|
f058850598 | ||
|
|
0c7220016b | ||
|
|
8c19124717 | ||
|
|
46f4359e1f | ||
|
|
f80452c7d9 | ||
|
|
611f1b57dd | ||
|
|
d1b1a45725 | ||
|
|
6e38f7e532 | ||
|
|
2c1e681984 | ||
|
|
f11ad99983 | ||
|
|
e1ef911d40 | ||
|
|
a4ca585ef2 | ||
|
|
076461a1b6 | ||
|
|
0c7407ebb6 | ||
|
|
f13a3fa549 | ||
|
|
c0a65eb593 | ||
|
|
450a7730d3 | ||
|
|
41ee4b642f | ||
|
|
3ee3c52e14 | ||
|
|
e76a5bfd85 | ||
|
|
59c89a3b9d | ||
|
|
8fc605037a | ||
|
|
311ddf82c5 | ||
|
|
9d65486c64 | ||
|
|
ccce7751a0 | ||
|
|
7252f0b490 | ||
|
|
094d2e586a | ||
|
|
6c1507c88c | ||
|
|
60f48326e1 | ||
|
|
5b985a924b | ||
|
|
ee74989f74 | ||
|
|
e2fc7e8cd7 | ||
|
|
aff55881df | ||
|
|
4d066a075d | ||
|
|
201077b6f6 | ||
|
|
795134c084 | ||
|
|
31c58409e1 |
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.7
|
||||
placeholder: v3.1.11
|
||||
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.7
|
||||
placeholder: v3.1.11
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -38,14 +38,26 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Yarn Package Manager
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
cache-dependency-path: netbox/project-static/yarn.lock
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: yarn --cwd netbox/project-static
|
||||
|
||||
- name: Install dependencies & set up configuration
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pycodestyle coverage
|
||||
pip install pycodestyle coverage tblib
|
||||
ln -s configuration.testing.py netbox/netbox/configuration.py
|
||||
yarn --cwd netbox/project-static
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
@@ -63,7 +75,7 @@ jobs:
|
||||
run: scripts/verify-bundles.sh
|
||||
|
||||
- name: Run tests
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
|
||||
|
||||
- name: Show coverage report
|
||||
run: coverage report --skip-covered --omit *migrations*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ markdown-include
|
||||
mkdocs-material
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/drkjam/netaddr
|
||||
# https://github.com/netaddr/netaddr
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
|
||||
@@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
|
||||
|
||||
Default: `{}` (Empty dictionary)
|
||||
|
||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
|
||||
|
||||
Default: `False`
|
||||
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
---
|
||||
|
||||
{!models/ipam/fhrpgroup.md!}
|
||||
{!models/ipam/fhrpgroupassignment.md!}
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -124,8 +124,10 @@ The demo data is provided in JSON format and loaded into an empty database using
|
||||
|
||||
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
|
||||
|
||||
When running tests, it's advised to use the special testing configuration file that ships with NetBox. This ensures that tests are run with the same configuration parameters consistently. To override your local configuration when running tests, set the `NETBOX_CONFIGURATION` environment variable to `netbox.configuration_testing`.
|
||||
|
||||
```no-highlight
|
||||
$ python manage.py test
|
||||
$ NETBOX_CONFIGURATION=netbox.configuration_testing python manage.py test
|
||||
```
|
||||
|
||||
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -6,7 +6,7 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
|
||||
|
||||
## Update Dependencies to Required Versions
|
||||
|
||||
NetBox v3.0 and later requires the following:
|
||||
NetBox v3.0 and later require the following:
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|------------|-----------------|
|
||||
@@ -67,6 +67,11 @@ sudo git checkout master
|
||||
sudo git pull origin master
|
||||
```
|
||||
|
||||
!!! info "Checking out an older release"
|
||||
If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do:
|
||||
|
||||
sudo git checkout v2.11.11
|
||||
|
||||
## Run the Upgrade Script
|
||||
|
||||
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
||||
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
|
||||
|
||||
For example, you might define a link like this:
|
||||
|
||||
@@ -32,6 +32,10 @@ The following context data is available within the template when rendering a cus
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
|
||||
While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type.
|
||||
|
||||
Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list.
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
|
||||
|
||||
@@ -8,9 +8,3 @@ A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to p
|
||||
* Gateway Load Balancing Protocol (GLBP)
|
||||
|
||||
NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
|
||||
|
||||
## FHRP Group Assignments
|
||||
|
||||
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
|
||||
|
||||
Interfaces are assigned to FHRP groups under the interface detail view.
|
||||
|
||||
5
docs/models/ipam/fhrpgroupassignment.md
Normal file
5
docs/models/ipam/fhrpgroupassignment.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# FHRP Group Assignments
|
||||
|
||||
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
|
||||
|
||||
Interfaces are assigned to FHRP groups under the interface detail view.
|
||||
@@ -3,7 +3,7 @@
|
||||
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
|
||||
|
||||
!!! note
|
||||
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
|
||||
All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts.
|
||||
|
||||
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
|
||||
|
||||
|
||||
@@ -1,5 +1,104 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## v3.1.11 (2022-04-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8163](https://github.com/netbox-community/netbox/issues/8163) - Show bridge interface members under interface view
|
||||
* [#8365](https://github.com/netbox-community/netbox/issues/8365) - Enable filtering child devices by parent device ID
|
||||
* [#8785](https://github.com/netbox-community/netbox/issues/8785) - Permit wildcard values in IP address DNS names
|
||||
* [#8790](https://github.com/netbox-community/netbox/issues/8790) - Include site and prefixes columns in VLAN group VLANs table
|
||||
* [#8830](https://github.com/netbox-community/netbox/issues/8830) - Add Checkpoint ClusterXL protocol for FHRP groups
|
||||
* [#8974](https://github.com/netbox-community/netbox/issues/8974) - Use monospace font for text areas in config revision form
|
||||
* [#9012](https://github.com/netbox-community/netbox/issues/9012) - Linkify circuits count in providers list
|
||||
* [#9036](https://github.com/netbox-community/netbox/issues/9036) - Add bulk edit capability for site contact fields
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8866](https://github.com/netbox-community/netbox/issues/8866) - Prevent exception when searching for a rack position with no rack specified under device edit view
|
||||
* [#9009](https://github.com/netbox-community/netbox/issues/9009) - Fix device count for racks in global search results
|
||||
|
||||
---
|
||||
|
||||
## v3.1.10 (2022-03-25)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8232](https://github.com/netbox-community/netbox/issues/8232) - Use a different color for 100% utilization bars
|
||||
* [#8457](https://github.com/netbox-community/netbox/issues/8457) - Enable adding non-racked devices from site & location views
|
||||
* [#8553](https://github.com/netbox-community/netbox/issues/8553) - Add missing object types to global search form
|
||||
* [#8575](https://github.com/netbox-community/netbox/issues/8575) - Add rack columns to cables list
|
||||
* [#8645](https://github.com/netbox-community/netbox/issues/8645) - Enable filtering objects by assigned contacts & contact roles
|
||||
* [#8926](https://github.com/netbox-community/netbox/issues/8926) - Add device type, role columns to device bay table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8696](https://github.com/netbox-community/netbox/issues/8696) - Fix help link under FHRP group assigment creation view
|
||||
* [#8813](https://github.com/netbox-community/netbox/issues/8813) - Retain global search bar query after submitting
|
||||
* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode
|
||||
* [#8850](https://github.com/netbox-community/netbox/issues/8850) - Show airflow field on device REST API serializer when config context data is included
|
||||
* [#8905](https://github.com/netbox-community/netbox/issues/8905) - Disable ordering by assigned tags to prevent erroneous results
|
||||
* [#8919](https://github.com/netbox-community/netbox/issues/8919) - Fix filtering of VLAN groups by site under prefix edit form
|
||||
* [#8924](https://github.com/netbox-community/netbox/issues/8924) - Improve load time of custom script list
|
||||
* [#8932](https://github.com/netbox-community/netbox/issues/8932) - Fix error when setting null value for interface `rf_role` via REST API
|
||||
* [#8935](https://github.com/netbox-community/netbox/issues/8935) - Correct ordering of next/previous racks to use naturalized names
|
||||
* [#8947](https://github.com/netbox-community/netbox/issues/8947) - Retain filter parameters when handling an export template exception
|
||||
* [#8951](https://github.com/netbox-community/netbox/issues/8951) - Allow changing device type & platform to different manufacturer simultaneously
|
||||
* [#8952](https://github.com/netbox-community/netbox/issues/8952) - Device images in rear rack elevations should be hyperlinked
|
||||
|
||||
---
|
||||
|
||||
## v3.1.9 (2022-03-07)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8594](https://github.com/netbox-community/netbox/issues/8594) - Enable filtering by exact description match for all applicable models
|
||||
* [#8629](https://github.com/netbox-community/netbox/issues/8629) - Add description to tag table search function
|
||||
* [#8664](https://github.com/netbox-community/netbox/issues/8664) - Show assigned ASNs/sites under list views
|
||||
* [#8736](https://github.com/netbox-community/netbox/issues/8736) - Add PC and UPC fiber end faces for LC/SC/LSH port types
|
||||
* [#8758](https://github.com/netbox-community/netbox/issues/8758) - Allow empty string substitution when renaming objects in bulk
|
||||
* [#8762](https://github.com/netbox-community/netbox/issues/8762) - Link to rack elevations list from site view
|
||||
* [#8766](https://github.com/netbox-community/netbox/issues/8766) - Add SCTP to service protocols list
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces
|
||||
* [#8633](https://github.com/netbox-community/netbox/issues/8633) - Prevent navigation sidebar pin from disappearing at certain breakpoints
|
||||
* [#8674](https://github.com/netbox-community/netbox/issues/8674) - Fix rendering of tabbed content in documentation
|
||||
* [#8710](https://github.com/netbox-community/netbox/issues/8710) - Fix dynamic scope selection form fields when creating a VLAN group
|
||||
* [#8713](https://github.com/netbox-community/netbox/issues/8713) - Restore missing "add" button on services list view
|
||||
* [#8715](https://github.com/netbox-community/netbox/issues/8715) - Avoid returning multiple objects when restricting querysets using multiple tags in permissions
|
||||
* [#8717](https://github.com/netbox-community/netbox/issues/8717) - Fix redirection after bulk edit/delete of prefixes from aggregate view
|
||||
* [#8724](https://github.com/netbox-community/netbox/issues/8724) - Fix exception during device import with invalid device type
|
||||
* [#8807](https://github.com/netbox-community/netbox/issues/8807) - Correct REST API URL for FHRP group assignments
|
||||
* [#8808](https://github.com/netbox-community/netbox/issues/8808) - Fix members count under FHRP group list
|
||||
|
||||
---
|
||||
|
||||
## v3.1.8 (2022-02-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
|
||||
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
|
||||
* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
|
||||
* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
|
||||
* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
|
||||
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
|
||||
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
|
||||
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
|
||||
* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
|
||||
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
|
||||
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
|
||||
* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
|
||||
* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
|
||||
|
||||
---
|
||||
|
||||
## v3.1.7 (2022-02-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -8,11 +8,13 @@ theme:
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
palette:
|
||||
- scheme: default
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
toggle:
|
||||
icon: material/lightbulb-outline
|
||||
name: Switch to Dark Mode
|
||||
- scheme: slate
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
@@ -34,7 +36,8 @@ markdown_extensions:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
nav:
|
||||
- Introduction: 'index.md'
|
||||
- Installation:
|
||||
|
||||
@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.filters import TagFilter
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
from .models import *
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterSet(PrimaryModelFilterSet):
|
||||
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -98,7 +98,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ProviderNetwork
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -115,10 +115,10 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -193,7 +193,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'install_date', 'commit_rate']
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -234,7 +234,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
|
||||
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.forms import CustomFieldModelFilterForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
|
||||
|
||||
__all__ = (
|
||||
@@ -16,12 +16,13 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterForm(CustomFieldModelFilterForm):
|
||||
class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Provider
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['asn'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -68,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Circuit
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -76,6 +77,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
['type_id', 'status', 'commit_rate'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
||||
@@ -2,7 +2,9 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
|
||||
)
|
||||
from .models import *
|
||||
|
||||
|
||||
@@ -53,11 +55,16 @@ class ProviderTable(BaseTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
circuit_count = tables.Column(
|
||||
circuit_count = LinkedCountColumn(
|
||||
accessor=Accessor('count_circuits'),
|
||||
viewname='circuits:circuit_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
@@ -66,7 +73,7 @@ class ProviderTable(BaseTable):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@@ -142,6 +149,9 @@ class CircuitTable(BaseTable):
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
)
|
||||
@@ -150,7 +160,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', 'created', 'last_updated',
|
||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||
|
||||
@@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
CircuitType.objects.bulk_create((
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1', description='foobar1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
|
||||
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
|
||||
))
|
||||
|
||||
@@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'slug': ['circuit-type-1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Circuit.objects.all()
|
||||
@@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
@@ -241,6 +245,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
|
||||
@@ -349,6 +357,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'xconnect_id': ['ABC', 'DEF']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_circuit_id(self):
|
||||
circuits = Circuit.objects.all()[:2]
|
||||
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
|
||||
@@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
provider_networks = (
|
||||
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
|
||||
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
|
||||
ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
|
||||
ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
|
||||
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
|
||||
)
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
@@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'name': ['Provider Network 1', 'Provider Network 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_provider(self):
|
||||
providers = Provider.objects.all()[:2]
|
||||
params = {'provider_id': [providers[0].pk, providers[1].pk]}
|
||||
|
||||
@@ -497,9 +497,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
|
||||
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
@@ -619,8 +619,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
|
||||
@@ -1013,13 +1013,19 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
TYPE_ST = 'st'
|
||||
TYPE_SC = 'sc'
|
||||
TYPE_SC_PC = 'sc-pc'
|
||||
TYPE_SC_UPC = 'sc-upc'
|
||||
TYPE_SC_APC = 'sc-apc'
|
||||
TYPE_FC = 'fc'
|
||||
TYPE_LC = 'lc'
|
||||
TYPE_LC_PC = 'lc-pc'
|
||||
TYPE_LC_UPC = 'lc-upc'
|
||||
TYPE_LC_APC = 'lc-apc'
|
||||
TYPE_MTRJ = 'mtrj'
|
||||
TYPE_MPO = 'mpo'
|
||||
TYPE_LSH = 'lsh'
|
||||
TYPE_LSH_PC = 'lsh-pc'
|
||||
TYPE_LSH_UPC = 'lsh-upc'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
@@ -1059,12 +1065,18 @@ class PortTypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_FC, 'FC'),
|
||||
(TYPE_LC, 'LC'),
|
||||
(TYPE_LC_PC, 'LC/PC'),
|
||||
(TYPE_LC_UPC, 'LC/UPC'),
|
||||
(TYPE_LC_APC, 'LC/APC'),
|
||||
(TYPE_LSH, 'LSH'),
|
||||
(TYPE_LSH_PC, 'LSH/PC'),
|
||||
(TYPE_LSH_UPC, 'LSH/UPC'),
|
||||
(TYPE_LSH_APC, 'LSH/APC'),
|
||||
(TYPE_MPO, 'MPO'),
|
||||
(TYPE_MTRJ, 'MTRJ'),
|
||||
(TYPE_SC, 'SC'),
|
||||
(TYPE_SC_PC, 'SC/PC'),
|
||||
(TYPE_SC_UPC, 'SC/UPC'),
|
||||
(TYPE_SC_APC, 'SC/APC'),
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_CS, 'CS'),
|
||||
|
||||
@@ -7,8 +7,8 @@ from ipam.models import ASN
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
|
||||
)
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from tenancy.models import *
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
@@ -62,7 +62,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterSet(OrganizationalModelFilterSet):
|
||||
class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Parent region (ID)',
|
||||
@@ -80,7 +80,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteGroupFilterSet(OrganizationalModelFilterSet):
|
||||
class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Parent site group (ID)',
|
||||
@@ -98,7 +98,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -142,7 +142,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email',
|
||||
'contact_email', 'description'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
@@ -237,10 +237,10 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -385,7 +385,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'created']
|
||||
fields = ['id', 'created', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet):
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
@@ -586,7 +586,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role']
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
|
||||
|
||||
|
||||
class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -639,6 +639,11 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
parent_device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='parent_bay__device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Parent Device (ID)',
|
||||
)
|
||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
label='Platform (ID)',
|
||||
@@ -1289,7 +1294,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet):
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -122,6 +122,18 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
label=_('ASNs'),
|
||||
required=False
|
||||
)
|
||||
contact_name = forms.CharField(
|
||||
max_length=50,
|
||||
required=False
|
||||
)
|
||||
contact_phone = forms.CharField(
|
||||
max_length=20,
|
||||
required=False
|
||||
)
|
||||
contact_email = forms.EmailField(
|
||||
required=False,
|
||||
label='Contact E-mail'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
@@ -134,7 +146,8 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone',
|
||||
'region', 'group', 'tenant', 'asn', 'asns', 'contact_name', 'contact_phone', 'contact_email', 'description',
|
||||
'time_zone',
|
||||
]
|
||||
|
||||
|
||||
@@ -1043,8 +1056,14 @@ class InterfaceBulkEditForm(
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.cleaned_data['mode']:
|
||||
if self.cleaned_data['untagged_vlan']:
|
||||
raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
|
||||
elif self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError({
|
||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||
})
|
||||
|
||||
@@ -605,6 +605,19 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
|
||||
'rf_channel_width', 'tx_power',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit interface choices for parent, bridge and lag to device only
|
||||
params = {}
|
||||
if data.get('device'):
|
||||
params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
|
||||
if params:
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
|
||||
@@ -5,9 +5,10 @@ from django.utils.translation import gettext as _
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from tenancy.models import *
|
||||
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
|
||||
from ipam.models import ASN
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import (
|
||||
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
|
||||
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
@@ -98,8 +99,13 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterForm(CustomFieldModelFilterForm):
|
||||
class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Region
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['parent_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -108,8 +114,13 @@ class RegionFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteGroupFilterForm(CustomFieldModelFilterForm):
|
||||
class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = SiteGroup
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['parent_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
@@ -118,13 +129,14 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Site
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['status', 'region_id', 'group_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['asn_id']
|
||||
['asn_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -149,12 +161,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Location
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id', 'parent_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -192,7 +205,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Rack
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -200,6 +213,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
['status', 'role_id'],
|
||||
['type', 'width', 'serial', 'asset_tag'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['contact', 'contact_role']
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -303,8 +317,12 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ManufacturerFilterForm(CustomFieldModelFilterForm):
|
||||
class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Manufacturer
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -390,7 +408,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = Device
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -402,6 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
|
||||
'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
|
||||
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
|
||||
],
|
||||
['contact', 'contact_role'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -636,11 +655,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPanelFilterForm(CustomFieldModelFilterForm):
|
||||
class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
|
||||
model = PowerPanel
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
('region_id', 'site_group_id', 'site_id', 'location_id'),
|
||||
('contact', 'contact_role')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@@ -605,11 +605,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
# can be flipped from one face to another.
|
||||
self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
|
||||
|
||||
# Limit platform by manufacturer
|
||||
self.fields['platform'].queryset = Platform.objects.filter(
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
|
||||
@@ -762,6 +762,10 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
|
||||
def is_lag(self):
|
||||
return self.type == InterfaceTypeChoices.TYPE_LAG
|
||||
|
||||
@property
|
||||
def is_bridge(self):
|
||||
return self.type == InterfaceTypeChoices.TYPE_BRIDGE
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
return self.cable or self.wireless_link
|
||||
|
||||
@@ -670,10 +670,11 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
})
|
||||
|
||||
# Prevent 0U devices from being assigned to a specific position
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
if hasattr(self, 'device_type'):
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
|
||||
if self.rack:
|
||||
|
||||
@@ -738,8 +739,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
if hasattr(self, 'device_type') and self.platform:
|
||||
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||
raise ValidationError({
|
||||
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||
'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
|
||||
f"this device's type belongs to {self.device_type.manufacturer}."
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
|
||||
@@ -412,7 +412,7 @@ class Rack(PrimaryModel):
|
||||
available_units.remove(u)
|
||||
|
||||
occupied_unit_count = self.u_height - len(available_units)
|
||||
percentage = int(float(occupied_unit_count) / self.u_height * 100)
|
||||
percentage = float(occupied_unit_count) / self.u_height * 100
|
||||
|
||||
return percentage
|
||||
|
||||
|
||||
@@ -126,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:
|
||||
@@ -140,10 +146,10 @@ class RackElevationSVG:
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
link.add(image)
|
||||
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
|
||||
@@ -23,6 +23,12 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Side A'
|
||||
)
|
||||
rack_a = tables.Column(
|
||||
accessor=Accessor('termination_a__device__rack'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Rack A'
|
||||
)
|
||||
termination_a = tables.Column(
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
@@ -35,6 +41,12 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Side B'
|
||||
)
|
||||
rack_b = tables.Column(
|
||||
accessor=Accessor('termination_b__device__rack'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
verbose_name='Rack B'
|
||||
)
|
||||
termination_b = tables.Column(
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
@@ -55,7 +67,7 @@ class CableTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -194,6 +194,9 @@ class DeviceTable(BaseTable):
|
||||
vc_priority = tables.Column(
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
@@ -204,8 +207,8 @@ class DeviceTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
|
||||
'last_updated',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
@@ -677,6 +680,15 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
device_role = ColoredLabelColumn(
|
||||
accessor=Accessor('installed_device__device_role'),
|
||||
verbose_name='Role'
|
||||
)
|
||||
device_type = tables.Column(
|
||||
accessor=Accessor('installed_device__device_type'),
|
||||
linkify=True,
|
||||
verbose_name='Type'
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
order_by=Accessor('installed_device__status')
|
||||
@@ -691,7 +703,7 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class ManufacturerTable(BaseTable):
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:manufacturer_list'
|
||||
)
|
||||
@@ -50,7 +53,7 @@ class ManufacturerTable(BaseTable):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'actions', 'created', 'last_updated',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
|
||||
@@ -27,13 +27,16 @@ class PowerPanelTable(BaseTable):
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerpanel_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ class RackTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
@@ -92,7 +95,7 @@ class RackTable(BaseTable):
|
||||
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', 'created', 'last_updated',
|
||||
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
|
||||
@@ -29,6 +29,9 @@ class RegionTable(BaseTable):
|
||||
url_params={'region_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:region_list'
|
||||
)
|
||||
@@ -36,7 +39,7 @@ class RegionTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Region
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -54,6 +57,9 @@ class SiteGroupTable(BaseTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:sitegroup_list'
|
||||
)
|
||||
@@ -61,7 +67,7 @@ class SiteGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -85,9 +91,16 @@ class SiteTable(BaseTable):
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name='ASN Count'
|
||||
)
|
||||
asns = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='ASNs'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
@@ -96,9 +109,9 @@ class SiteTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
@@ -126,6 +139,9 @@ class LocationTable(BaseTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
@@ -137,7 +153,7 @@ class LocationTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
|
||||
'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
|
||||
|
||||
@@ -298,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>
|
||||
|
||||
@@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com', description='foobar1'),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com', description='foobar2'),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
@@ -201,6 +201,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'contact_email': ['contact1@example.com', 'contact2@example.com']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -329,8 +333,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
rack_roles = (
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00', description='foobar2'),
|
||||
RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
|
||||
)
|
||||
RackRole.objects.bulk_create(rack_roles)
|
||||
@@ -347,6 +351,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'color': ['ff0000', '00ff00']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Rack.objects.all()
|
||||
@@ -570,8 +578,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
reservations = (
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]),
|
||||
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]),
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
|
||||
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
|
||||
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
|
||||
)
|
||||
RackReservation.objects.bulk_create(reservations)
|
||||
@@ -604,6 +612,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_group(self):
|
||||
tenant_groups = TenantGroup.objects.all()[:2]
|
||||
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
|
||||
@@ -1088,8 +1100,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True),
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
@@ -1112,6 +1124,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'vm_role': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Platform.objects.all()
|
||||
|
||||
@@ -328,6 +328,11 @@ class SiteView(generic.ObjectView):
|
||||
'device_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=instance)
|
||||
nonracked_devices = Device.objects.filter(
|
||||
site=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
|
||||
asn_count = asns.count()
|
||||
@@ -338,6 +343,7 @@ class SiteView(generic.ObjectView):
|
||||
'stats': stats,
|
||||
'locations': locations,
|
||||
'asns': asns,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -415,11 +421,17 @@ class LocationView(generic.ObjectView):
|
||||
).filter(pk__in=location_ids).exclude(pk=instance.pk)
|
||||
child_locations_table = tables.LocationTable(child_locations)
|
||||
paginate_table(child_locations_table, request)
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
return {
|
||||
'rack_count': rack_count,
|
||||
'device_count': device_count,
|
||||
'child_locations_table': child_locations_table,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -597,8 +609,8 @@ class RackView(generic.ObjectView):
|
||||
peer_racks = peer_racks.filter(location=instance.location)
|
||||
else:
|
||||
peer_racks = peer_racks.filter(location__isnull=True)
|
||||
next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
|
||||
next_rack = peer_racks.filter(_name__gt=instance._name).first()
|
||||
prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first()
|
||||
|
||||
reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
|
||||
power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(
|
||||
@@ -1764,6 +1776,14 @@ class InterfaceView(generic.ObjectView):
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Get bridge interfaces
|
||||
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
|
||||
bridge_interfaces_tables = tables.InterfaceTable(
|
||||
bridge_interfaces,
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Get child interfaces
|
||||
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
child_interfaces_tables = tables.InterfaceTable(
|
||||
@@ -1788,6 +1808,7 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'ipaddress_table': ipaddress_table,
|
||||
'bridge_interfaces_table': bridge_interfaces_tables,
|
||||
'child_interfaces_table': child_interfaces_tables,
|
||||
'vlan_table': vlan_table,
|
||||
}
|
||||
|
||||
@@ -23,15 +23,18 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
('Banners', {
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('Pagination', {
|
||||
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
|
||||
}),
|
||||
('Validation', {
|
||||
'fields': ('CUSTOM_VALIDATORS',),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('NAPALM', {
|
||||
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('Miscellaneous', {
|
||||
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'),
|
||||
|
||||
@@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -103,7 +103,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name']
|
||||
fields = ['id', 'content_type', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -177,14 +177,15 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(slug__icontains=value)
|
||||
Q(slug__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def _content_type(self, queryset, name, values):
|
||||
@@ -317,6 +318,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
tag_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tags',
|
||||
queryset=Tag.objects.all(),
|
||||
label='Tag',
|
||||
)
|
||||
tag = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tags__slug',
|
||||
queryset=Tag.objects.all(),
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
@@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
||||
required=False
|
||||
)
|
||||
button_class = forms.ChoiceField(
|
||||
choices=CustomLinkButtonClassChoices,
|
||||
choices=add_blank_choice(CustomLinkButtonClassChoices),
|
||||
required=False,
|
||||
widget=StaticSelect()
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -259,6 +259,10 @@ class BaseScript:
|
||||
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
|
||||
functionality for use in other subclasses.
|
||||
"""
|
||||
|
||||
# Prevent django from instantiating the class on all accesses
|
||||
do_not_call_in_templates = True
|
||||
|
||||
class Meta:
|
||||
pass
|
||||
|
||||
@@ -280,7 +284,7 @@ class BaseScript:
|
||||
|
||||
@classproperty
|
||||
def name(self):
|
||||
return getattr(self.Meta, 'name', self.__class__.__name__)
|
||||
return getattr(self.Meta, 'name', self.__name__)
|
||||
|
||||
@classproperty
|
||||
def full_name(self):
|
||||
|
||||
@@ -29,6 +29,11 @@ CONFIGCONTEXT_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_FULL_NAME = """
|
||||
{% load helpers %}
|
||||
{{ record.user.get_full_name|placeholder }}
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
@@ -204,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'
|
||||
@@ -219,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):
|
||||
|
||||
@@ -12,7 +12,7 @@ from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||
|
||||
export_templates = (
|
||||
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'),
|
||||
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'),
|
||||
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'),
|
||||
)
|
||||
ExportTemplate.objects.bulk_create(export_templates)
|
||||
@@ -167,6 +167,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'content_type': ContentType.objects.get(model='site').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
@@ -429,6 +433,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
for i in range(0, 3):
|
||||
is_active = bool(i % 2)
|
||||
c = ConfigContext.objects.create(
|
||||
@@ -446,6 +452,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
c.clusters.set([clusters[i]])
|
||||
c.tenant_groups.set([tenant_groups[i]])
|
||||
c.tenants.set([tenants[i]])
|
||||
c.tags.set([tags[i]])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Config Context 1', 'Config Context 2']}
|
||||
@@ -516,13 +523,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_(self):
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tags(self):
|
||||
tags = Tag.objects.all()[:2]
|
||||
params = {'tag_id': [tags[0].pk, tags[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tag': [tags[0].slug, tags[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Tag.objects.all()
|
||||
@@ -532,8 +546,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1', color='ff0000'),
|
||||
Tag(name='Tag 2', slug='tag-2', color='00ff00'),
|
||||
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
|
||||
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
|
||||
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
|
||||
)
|
||||
Tag.objects.bulk_create(tags)
|
||||
@@ -557,6 +571,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'color': ['ff0000', '00ff00']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type': ['dcim.site', 'circuits.provider']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
|
||||
)
|
||||
objectchanges_table = tables.ObjectChangeTable(
|
||||
data=objectchanges,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
paginate_table(objectchanges_table, request)
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
|
||||
|
||||
|
||||
class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
|
||||
group = NestedFHRPGroupSerializer()
|
||||
interface_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
|
||||
@@ -135,14 +135,22 @@ class FHRPGroupProtocolChoices(ChoiceSet):
|
||||
PROTOCOL_HSRP = 'hsrp'
|
||||
PROTOCOL_GLBP = 'glbp'
|
||||
PROTOCOL_CARP = 'carp'
|
||||
PROTOCOL_CLUSTERXL = 'clusterxl'
|
||||
PROTOCOL_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
(PROTOCOL_VRRP2, 'VRRPv2'),
|
||||
(PROTOCOL_VRRP3, 'VRRPv3'),
|
||||
(PROTOCOL_HSRP, 'HSRP'),
|
||||
(PROTOCOL_GLBP, 'GLBP'),
|
||||
(PROTOCOL_CARP, 'CARP'),
|
||||
('Standard', (
|
||||
(PROTOCOL_VRRP2, 'VRRPv2'),
|
||||
(PROTOCOL_VRRP3, 'VRRPv3'),
|
||||
(PROTOCOL_CARP, 'CARP'),
|
||||
)),
|
||||
('CheckPoint', (
|
||||
(PROTOCOL_CLUSTERXL, 'ClusterXL'),
|
||||
)),
|
||||
('Cisco', (
|
||||
(PROTOCOL_HSRP, 'HSRP'),
|
||||
(PROTOCOL_GLBP, 'GLBP'),
|
||||
)),
|
||||
(PROTOCOL_OTHER, 'Other'),
|
||||
)
|
||||
|
||||
@@ -189,8 +197,10 @@ class ServiceProtocolChoices(ChoiceSet):
|
||||
|
||||
PROTOCOL_TCP = 'tcp'
|
||||
PROTOCOL_UDP = 'udp'
|
||||
PROTOCOL_SCTP = 'sctp'
|
||||
|
||||
CHOICES = (
|
||||
(PROTOCOL_TCP, 'TCP'),
|
||||
(PROTOCOL_UDP, 'UDP'),
|
||||
(PROTOCOL_SCTP, 'SCTP'),
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique']
|
||||
fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -117,7 +117,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RouteTarget
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
|
||||
class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
@@ -155,7 +155,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'date_added']
|
||||
fields = ['id', 'date_added', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -203,7 +203,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ASN
|
||||
fields = ['id', 'asn']
|
||||
fields = ['id', 'asn', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -225,7 +225,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
@@ -334,7 +334,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
vlan_vid = django_filters.NumberFilter(
|
||||
field_name='vlan__vid',
|
||||
label='VLAN number (1-4095)',
|
||||
label='VLAN number (1-4094)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Role.objects.all(),
|
||||
@@ -354,7 +354,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'is_pool', 'mark_utilized']
|
||||
fields = ['id', 'is_pool', 'mark_utilized', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -460,7 +460,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
fields = ['id']
|
||||
fields = ['id', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -839,7 +839,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'vid', 'name']
|
||||
fields = ['id', 'vid', 'name', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -891,7 +891,7 @@ class ServiceFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'name', 'protocol']
|
||||
fields = ['id', 'name', 'protocol', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -375,7 +375,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
|
||||
model = VLAN
|
||||
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
|
||||
help_texts = {
|
||||
'vid': 'Numeric VLAN ID (1-4095)',
|
||||
'vid': 'Numeric VLAN ID (1-4094)',
|
||||
'name': 'VLAN name',
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
|
||||
label='VLAN group',
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
'site': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'vlans': '$vlan'
|
||||
|
||||
@@ -50,7 +50,7 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(default='active', max_length=50)),
|
||||
('role', models.CharField(blank=True, max_length=50)),
|
||||
('assigned_object_id', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('dns_name', models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')])),
|
||||
('dns_name', models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names', regex='^([0-9A-Za-z_-]+|\\*)(\\.[0-9A-Za-z_-]+)*\\.?$')])),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
],
|
||||
options={
|
||||
|
||||
@@ -248,7 +248,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = float(child_prefixes.size) / self.prefix.size * 100
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
@@ -548,7 +548,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
vrf=self.vrf
|
||||
)
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = float(child_prefixes.size) / self.prefix.size * 100
|
||||
else:
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_ips = netaddr.IPSet(
|
||||
@@ -558,7 +558,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
prefix_size = self.prefix.size
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
utilization = int(float(child_ips.size) / prefix_size * 100)
|
||||
utilization = float(child_ips.size) / prefix_size * 100
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
interface_count = tables.Column(
|
||||
verbose_name='Interfaces'
|
||||
member_count = tables.Column(
|
||||
verbose_name='Members'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:fhrpgroup_list'
|
||||
@@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = FHRPGroup
|
||||
fields = (
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count')
|
||||
|
||||
|
||||
class FHRPGroupAssignmentTable(BaseTable):
|
||||
|
||||
@@ -117,6 +117,10 @@ class ASNTable(BaseTable):
|
||||
site_count = LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Site Count'
|
||||
)
|
||||
sites = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='Sites'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
@@ -129,7 +133,7 @@ class ASNTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ASN
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created',
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'actions', 'created',
|
||||
'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')
|
||||
|
||||
@@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
]
|
||||
|
||||
asns = (
|
||||
ASN(asn=64512, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0]),
|
||||
ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
|
||||
ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
|
||||
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
|
||||
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
|
||||
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
|
||||
@@ -86,6 +86,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VRF.objects.all()
|
||||
@@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False),
|
||||
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False),
|
||||
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False, description='foobar1'),
|
||||
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False, description='foobar2'),
|
||||
VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
|
||||
VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
|
||||
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
|
||||
@@ -174,6 +178,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RouteTarget.objects.all()
|
||||
@@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
route_targets = (
|
||||
RouteTarget(name='65000:1001', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1002', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
|
||||
RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
|
||||
RouteTarget(name='65000:1003', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:1004', tenant=tenants[0]),
|
||||
RouteTarget(name='65000:2001', tenant=tenants[1]),
|
||||
@@ -256,6 +264,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RIR.objects.all()
|
||||
@@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
aggregates = (
|
||||
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'),
|
||||
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'),
|
||||
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01', description='foobar1'),
|
||||
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02', description='foobar2'),
|
||||
Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'),
|
||||
Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'),
|
||||
Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'),
|
||||
@@ -340,6 +352,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'date_added': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Test for multiple values
|
||||
def test_prefix(self):
|
||||
params = {'prefix': '10.1.0.0/16'}
|
||||
@@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
roles = (
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 1', slug='role-1', description='foobar1'),
|
||||
Role(name='Role 2', slug='role-2', description='foobar2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
)
|
||||
Role.objects.bulk_create(roles)
|
||||
@@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'slug': ['role-1', 'role-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Prefix.objects.all()
|
||||
@@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
prefixes = (
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
|
||||
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
@@ -601,6 +621,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = IPRange.objects.all()
|
||||
@@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
ip_ranges = (
|
||||
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
|
||||
IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
|
||||
IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
|
||||
@@ -692,6 +716,10 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = IPAddress.objects.all()
|
||||
@@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
vlans = (
|
||||
# Create one VLAN per VLANGroup
|
||||
VLAN(vid=1, name='Region 1', group=groups[0]),
|
||||
VLAN(vid=2, name='Region 2', group=groups[1]),
|
||||
VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
|
||||
VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
|
||||
VLAN(vid=3, name='Region 3', group=groups[2]),
|
||||
VLAN(vid=4, name='Site Group 1', group=groups[3]),
|
||||
VLAN(vid=5, name='Site Group 2', group=groups[4]),
|
||||
@@ -1271,6 +1299,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
roles = Role.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
@@ -1337,8 +1369,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
services = (
|
||||
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
|
||||
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
|
||||
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001], description='foobar1'),
|
||||
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002], description='foobar2'),
|
||||
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
|
||||
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
|
||||
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
|
||||
@@ -1354,6 +1386,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_port(self):
|
||||
params = {'port': '1001'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -204,11 +204,11 @@ class TestPrefix(TestCase):
|
||||
IPAddress.objects.bulk_create([
|
||||
IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
|
||||
])
|
||||
self.assertEqual(prefix.get_utilization(), 12) # 12.5% utilization
|
||||
self.assertEqual(prefix.get_utilization(), 32 / 254 * 100) # ~12.5% utilization
|
||||
|
||||
# Create a child range with 32 additional IPs
|
||||
IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
|
||||
self.assertEqual(prefix.get_utilization(), 25) # 25% utilization
|
||||
self.assertEqual(prefix.get_utilization(), 64 / 254 * 100) # ~25% utilization
|
||||
|
||||
#
|
||||
# Uniqueness enforcement tests
|
||||
|
||||
@@ -24,7 +24,7 @@ class MinPrefixLengthValidator(BaseValidator):
|
||||
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex='^[0-9A-Za-z._-]+$',
|
||||
message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names',
|
||||
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
|
||||
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
|
||||
code='invalid'
|
||||
)
|
||||
|
||||
@@ -795,7 +795,7 @@ class VLANGroupView(generic.ObjectView):
|
||||
vlans_count = vlans.count()
|
||||
vlans = add_available_vlans(vlans, vlan_group=instance)
|
||||
|
||||
vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
|
||||
vlans_table = tables.VLANTable(vlans, exclude=('group',))
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
vlans_table.columns.show('pk')
|
||||
paginate_table(vlans_table, request)
|
||||
@@ -1038,7 +1038,6 @@ class ServiceListView(generic.ObjectListView):
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
filterset_form = forms.ServiceFilterForm
|
||||
table = tables.ServiceTable
|
||||
action_buttons = ('import', 'export')
|
||||
|
||||
|
||||
class ServiceView(generic.ObjectView):
|
||||
|
||||
@@ -20,19 +20,34 @@ 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(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
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(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
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(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
# IPAM
|
||||
@@ -100,7 +115,12 @@ PARAMS = (
|
||||
label='Custom validators',
|
||||
default={},
|
||||
description="Custom validation rules (JSON)",
|
||||
field=forms.JSONField
|
||||
field=forms.JSONField,
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
# NAPALM
|
||||
@@ -128,7 +148,12 @@ PARAMS = (
|
||||
label='NAPALM arguments',
|
||||
default={},
|
||||
description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)",
|
||||
field=forms.JSONField
|
||||
field=forms.JSONField,
|
||||
field_kwargs={
|
||||
'widget': forms.Textarea(
|
||||
attrs={'class': 'vLargeTextField'}
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
# Miscellaneous
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Dict
|
||||
|
||||
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
@@ -18,7 +19,7 @@ from ipam.filtersets import (
|
||||
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
|
||||
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
|
||||
@@ -26,169 +27,214 @@ from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineTable
|
||||
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
),
|
||||
'filterset': CircuitFilterSet,
|
||||
'table': CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
}),
|
||||
('providernetwork', {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': ProviderNetworkFilterSet,
|
||||
'table': ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
}),
|
||||
# DCIM
|
||||
('site', {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant'),
|
||||
'filterset': SiteFilterSet,
|
||||
'table': SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
|
||||
'filterset': RackFilterSet,
|
||||
'table': RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': RackReservationFilterSet,
|
||||
'table': RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
|
||||
CIRCUIT_TYPES = OrderedDict(
|
||||
(
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': LocationFilterSet,
|
||||
'table': LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': DeviceFilterSet,
|
||||
'table': DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': CableFilterSet,
|
||||
'table': CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': PowerFeedFilterSet,
|
||||
'table': PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': VirtualMachineFilterSet,
|
||||
'table': VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
# IPAM
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.prefetch_related('tenant'),
|
||||
'filterset': VRFFilterSet,
|
||||
'table': VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': AggregateFilterSet,
|
||||
'table': AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filterset': PrefixFilterSet,
|
||||
'table': PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
|
||||
'filterset': IPAddressFilterSet,
|
||||
'table': IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
|
||||
'filterset': VLANFilterSet,
|
||||
'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'),
|
||||
'filterset': TenantFilterSet,
|
||||
'table': TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
('contact', {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments'),
|
||||
'filterset': ContactFilterSet,
|
||||
'table': ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
}),
|
||||
))
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
),
|
||||
'filterset': CircuitFilterSet,
|
||||
'table': CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
}),
|
||||
('providernetwork', {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': ProviderNetworkFilterSet,
|
||||
'table': ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
DCIM_TYPES = OrderedDict(
|
||||
(
|
||||
('site', {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant'),
|
||||
'filterset': SiteFilterSet,
|
||||
'table': SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
),
|
||||
'filterset': RackFilterSet,
|
||||
'table': RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': RackReservationFilterSet,
|
||||
'table': RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': LocationFilterSet,
|
||||
'table': LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': DeviceFilterSet,
|
||||
'table': DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': CableFilterSet,
|
||||
'table': CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': PowerFeedFilterSet,
|
||||
'table': PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
IPAM_TYPES = OrderedDict(
|
||||
(
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.prefetch_related('tenant'),
|
||||
'filterset': VRFFilterSet,
|
||||
'table': VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': AggregateFilterSet,
|
||||
'table': AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filterset': PrefixFilterSet,
|
||||
'table': PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
|
||||
'filterset': IPAddressFilterSet,
|
||||
'table': IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
|
||||
'filterset': VLANFilterSet,
|
||||
'table': VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
}),
|
||||
('asn', {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
|
||||
'filterset': ASNFilterSet,
|
||||
'table': ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
TENANCY_TYPES = OrderedDict(
|
||||
(
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
'filterset': TenantFilterSet,
|
||||
'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',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
VIRTUALIZATION_TYPES = OrderedDict(
|
||||
(
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': VirtualMachineFilterSet,
|
||||
'table': VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
SEARCH_TYPE_HIERARCHY = OrderedDict(
|
||||
(
|
||||
("Circuits", CIRCUIT_TYPES),
|
||||
("DCIM", DCIM_TYPES),
|
||||
("IPAM", IPAM_TYPES),
|
||||
("Tenancy", TENANCY_TYPES),
|
||||
("Virtualization", VIRTUALIZATION_TYPES),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_search_types() -> Dict[str, Dict]:
|
||||
result = dict()
|
||||
|
||||
for app_types in SEARCH_TYPE_HIERARCHY.values():
|
||||
for name, items in app_types.items():
|
||||
result[name] = items
|
||||
|
||||
return result
|
||||
|
||||
|
||||
SEARCH_TYPES = build_search_types()
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
from django import forms
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
from netbox.constants import SEARCH_TYPE_HIERARCHY
|
||||
|
||||
OBJ_TYPE_CHOICES = (
|
||||
('', 'All Objects'),
|
||||
('Circuits', (
|
||||
('provider', 'Providers'),
|
||||
('circuit', 'Circuits'),
|
||||
)),
|
||||
('DCIM', (
|
||||
('site', 'Sites'),
|
||||
('rack', 'Racks'),
|
||||
('rackreservation', 'Rack reservations'),
|
||||
('location', 'Locations'),
|
||||
('devicetype', 'Device Types'),
|
||||
('device', 'Devices'),
|
||||
('virtualchassis', 'Virtual chassis'),
|
||||
('cable', 'Cables'),
|
||||
('powerfeed', 'Power feeds'),
|
||||
)),
|
||||
('IPAM', (
|
||||
('vrf', 'VRFs'),
|
||||
('aggregate', 'Aggregates'),
|
||||
('prefix', 'Prefixes'),
|
||||
('ipaddress', 'IP Addresses'),
|
||||
('vlan', 'VLANs'),
|
||||
)),
|
||||
('Tenancy', (
|
||||
('tenant', 'Tenants'),
|
||||
)),
|
||||
('Virtualization', (
|
||||
('cluster', 'Clusters'),
|
||||
('virtualmachine', 'Virtual Machines'),
|
||||
)),
|
||||
)
|
||||
|
||||
def build_search_choices():
|
||||
result = list()
|
||||
result.append(('', 'All Objects'))
|
||||
for category, items in SEARCH_TYPE_HIERARCHY.items():
|
||||
subcategories = list()
|
||||
for slug, obj in items.items():
|
||||
name = obj['queryset'].model._meta.verbose_name_plural
|
||||
name = name[0].upper() + name[1:]
|
||||
subcategories.append((slug, name))
|
||||
result.append((category, tuple(subcategories)))
|
||||
|
||||
return tuple(result)
|
||||
|
||||
|
||||
OBJ_TYPE_CHOICES = build_search_choices()
|
||||
|
||||
|
||||
def build_options():
|
||||
|
||||
@@ -19,7 +19,7 @@ from netbox.config import PARAMS
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.1.7'
|
||||
VERSION = '3.1.11'
|
||||
|
||||
# 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
|
||||
|
||||
@@ -212,7 +212,10 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
return template.render_to_response(self.queryset)
|
||||
except Exception as e:
|
||||
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
|
||||
return redirect(request.path)
|
||||
# Strip the `export` param and redirect user to the filtered objects list
|
||||
query_params = request.GET.copy()
|
||||
query_params.pop('export')
|
||||
return redirect(f'{request.path}?{query_params.urlencode()}')
|
||||
|
||||
def get(self, request):
|
||||
model = self.queryset.model
|
||||
|
||||
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
16
netbox/project-static/dist/netbox.js
vendored
16
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/status.js
vendored
2
netbox/project-static/dist/status.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/status.js.map
vendored
2
netbox/project-static/dist/status.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,16 @@ type ShowHideMap = {
|
||||
*
|
||||
* @example vlangroup_edit
|
||||
*/
|
||||
[view: string]: {
|
||||
[view: string]: string;
|
||||
};
|
||||
|
||||
type ShowHideLayout = {
|
||||
/**
|
||||
* Name of layout config
|
||||
*
|
||||
* @example vlangroup
|
||||
*/
|
||||
[config: string]: {
|
||||
/**
|
||||
* Default layout.
|
||||
*/
|
||||
@@ -19,15 +28,15 @@ type ShowHideMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of scope names to arrays of object types whose fields should be hidden or shown when
|
||||
* Mapping of layout names to arrays of object types whose fields should be hidden or shown when
|
||||
* the scope type (key) is selected.
|
||||
*
|
||||
* For example, if `region` is the scope type, the fields with IDs listed in
|
||||
* showHideMap.region.hide should be hidden, and the fields with IDs listed in
|
||||
* showHideMap.region.show should be shown.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_edit: {
|
||||
const showHideLayout: ShowHideLayout = {
|
||||
vlangroup: {
|
||||
region: {
|
||||
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region'],
|
||||
@@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of view names to layout configurations
|
||||
*
|
||||
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_add: 'vlangroup',
|
||||
vlangroup_edit: 'vlangroup',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle visibility of a given element's parent.
|
||||
* @param query CSS Query.
|
||||
@@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
|
||||
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
|
||||
// Scope type's innerText looks something like `DCIM > region`.
|
||||
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
|
||||
const layoutConfig = showHideMap[view];
|
||||
|
||||
for (const [scope, fields] of Object.entries(showHideMap[view])) {
|
||||
for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
|
||||
// If the scope type ends with the specified scope, toggle its field visibility according to
|
||||
// the show/hide values.
|
||||
if (scopeType.endsWith(scope)) {
|
||||
@@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
|
||||
break;
|
||||
} else {
|
||||
// Otherwise, hide all fields.
|
||||
for (const field of showHideMap[view].default.hide) {
|
||||
for (const field of showHideLayout[layoutConfig].default.hide) {
|
||||
toggleParentVisibility(`#${field}`, 'hide');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
|
||||
import { isStaticParams, isOption } from './types';
|
||||
import {
|
||||
hasMore,
|
||||
isTruthy,
|
||||
hasError,
|
||||
getElement,
|
||||
isTruthy,
|
||||
getApiData,
|
||||
getElement,
|
||||
isApiError,
|
||||
replaceAll,
|
||||
createElement,
|
||||
uniqueByProperty,
|
||||
findFirstAdjacent,
|
||||
@@ -461,7 +462,7 @@ export class APISelect {
|
||||
// Set any primitive k/v pairs as data attributes on each option.
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||
const key = k.replaceAll('_', '-');
|
||||
const key = replaceAll(k, '_', '-');
|
||||
data[key] = String(v);
|
||||
}
|
||||
// Set option to disabled if the result contains a matching key and is truthy.
|
||||
@@ -556,9 +557,12 @@ export class APISelect {
|
||||
private async handleSearch(event: Event) {
|
||||
const { value: q } = event.target as HTMLInputElement;
|
||||
const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
|
||||
await this.fetchOptions(url, 'merge');
|
||||
this.slim.data.search(q);
|
||||
this.slim.render();
|
||||
if (!url.includes(`{{`)) {
|
||||
await this.fetchOptions(url, 'merge');
|
||||
this.slim.data.search(q);
|
||||
this.slim.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,7 +663,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 +745,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 +923,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);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ $theme-colors: (
|
||||
'danger': $danger,
|
||||
'light': $light,
|
||||
'dark': $dark,
|
||||
|
||||
// General-purpose palette
|
||||
'blue': $blue-300,
|
||||
'indigo': $indigo-300,
|
||||
@@ -37,7 +36,7 @@ $theme-colors: (
|
||||
'cyan': $cyan-300,
|
||||
'gray': $gray-300,
|
||||
'black': $black,
|
||||
'white': $white,
|
||||
'white': $white
|
||||
);
|
||||
|
||||
// Gradient
|
||||
@@ -146,9 +145,9 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
|
||||
$nav-pills-link-active-color: $component-active-color;
|
||||
$nav-pills-link-active-bg: $component-active-bg;
|
||||
|
||||
$navbar-light-color: $darkest;
|
||||
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
|
||||
$navbar-light-color: $darker;
|
||||
$navbar-light-toggler-border-color: $gray-700;
|
||||
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-toggler-border-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
|
||||
|
||||
// Dropdowns
|
||||
$dropdown-color: $body-color;
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
function checkSideNav() {
|
||||
// Check localStorage to see if the sidebar should be pinned.
|
||||
var sideNavRaw = localStorage.getItem('netbox-sidenav');
|
||||
// Determine if the device has a small screeen. This media query is equivalent to
|
||||
@@ -154,11 +154,15 @@
|
||||
// jumpy/glitchy behavior on page reloads.
|
||||
document.body.setAttribute('data-sidenav-pinned', '');
|
||||
document.body.setAttribute('data-sidenav-show', '');
|
||||
document.body.removeAttribute('data-sidenav-hidden');
|
||||
} else {
|
||||
document.body.removeAttribute('data-sidenav-pinned');
|
||||
document.body.setAttribute('data-sidenav-hidden', '');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
window.addEventListener('resize', function(){ checkSideNav() });
|
||||
checkSideNav();
|
||||
</script>
|
||||
|
||||
{# Page layout #}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
|
||||
{% search_options %}
|
||||
{% search_options request %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
{# Search bar #}
|
||||
<div class="col-6 d-flex flex-grow-1 justify-content-center">
|
||||
{% search_options %}
|
||||
{% search_options request %}
|
||||
</div>
|
||||
|
||||
{# Proflie/login button #}
|
||||
@@ -108,56 +108,58 @@
|
||||
|
||||
{# Page footer #}
|
||||
<footer class="footer container-fluid">
|
||||
<div class="row align-items-center justify-content-between mx-0">
|
||||
|
||||
{# Docs & Community Links #}
|
||||
<div class="col-sm-12 col-md-auto fs-4 noprint">
|
||||
<nav class="nav justify-content-center justify-content-lg-start">
|
||||
{# Documentation #}
|
||||
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
|
||||
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# REST API #}
|
||||
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
|
||||
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# API docs #}
|
||||
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
|
||||
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# GraphQL API #}
|
||||
{% if config.GRAPHQL_ENABLED %}
|
||||
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
|
||||
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# GitHub #}
|
||||
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
|
||||
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# NetDev Slack #}
|
||||
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
|
||||
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
</nav>
|
||||
{% block footer %}
|
||||
<div class="row align-items-center justify-content-between mx-0">
|
||||
|
||||
<div class="col-sm-12 col-md-auto fs-4 noprint">
|
||||
<nav class="nav justify-content-center justify-content-lg-start">
|
||||
{% block footer_links %}
|
||||
{# Documentation #}
|
||||
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
|
||||
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# REST API #}
|
||||
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
|
||||
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# API docs #}
|
||||
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
|
||||
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# GraphQL API #}
|
||||
{% if config.GRAPHQL_ENABLED %}
|
||||
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
|
||||
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# GitHub #}
|
||||
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
|
||||
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
|
||||
{# NetDev Slack #}
|
||||
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
|
||||
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
|
||||
</a>
|
||||
{% endblock footer_links %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
|
||||
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
|
||||
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# System Info #}
|
||||
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
|
||||
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
|
||||
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock footer %}
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
{% endblock layout %}
|
||||
{% endblock layout %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -37,9 +37,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Serial Number</th>
|
||||
<td>
|
||||
<code id="serial_number"></code>
|
||||
</td>
|
||||
<td id="serial_number" class="text-monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">OS Version</th>
|
||||
|
||||
@@ -8,6 +8,22 @@
|
||||
<a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% if termination.device.site %}
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
<a href="{{ termination.device.site.get_absolute_url }}">{{ termination.device.site }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if termination.device.rack %}
|
||||
<tr>
|
||||
<td>Rack</td>
|
||||
<td>
|
||||
<a href="{{ termination.device.rack.get_absolute_url }}">{{ termination.device.rack }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>
|
||||
|
||||
62
netbox/templates/dcim/inc/nonracked_devices.html
Normal file
62
netbox/templates/dcim/inc/nonracked_devices.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Non-Racked Devices
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if nonracked_devices %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Type</th>
|
||||
<th colspan="2">Parent Device</th>
|
||||
</tr>
|
||||
{% for device in nonracked_devices %}
|
||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
|
||||
</td>
|
||||
<td>{{ device.device_role }}</td>
|
||||
<td>{{ device.device_type }}</td>
|
||||
{% if device.parent_bay %}
|
||||
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
|
||||
<td>{{ device.parent_bay }}</td>
|
||||
{% else %}
|
||||
<td colspan="2" class="text-muted">—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.add_device %}
|
||||
{% if object|meta:'verbose_name' == 'rack' %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Add a Non-Racked Device
|
||||
</a>
|
||||
</div>
|
||||
{% elif object|meta:'verbose_name' == 'site' %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Add a Non-Racked Device
|
||||
</a>
|
||||
</div>
|
||||
{% elif object|meta:'verbose_name' == 'location' %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Add a Non-Racked Device
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -467,6 +467,13 @@
|
||||
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
|
||||
</div>
|
||||
</div>
|
||||
{% if object.is_bridge %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
|
||||
@@ -288,50 +288,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Non-Racked Devices
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if nonracked_devices %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Type</th>
|
||||
<th colspan="2">Parent Device</th>
|
||||
</tr>
|
||||
{% for device in nonracked_devices %}
|
||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
|
||||
</td>
|
||||
<td>{{ device.device_role }}</td>
|
||||
<td>{{ device.device_type }}</td>
|
||||
{% if device.parent_bay %}
|
||||
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
|
||||
<td>{{ device.parent_bay }}</td>
|
||||
{% else %}
|
||||
<td colspan="2" class="text-muted">—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.add_device %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Add a Non-Racked Device
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,14 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">AS Number</th>
|
||||
<td>{{ object.asn|placeholder }}</td>
|
||||
<td>
|
||||
{% if object.asn %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="This field will be removed in NetBox v3.2. Please migrate this data to ASN objects."></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ object.asn|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Time Zone</th>
|
||||
@@ -128,50 +135,44 @@
|
||||
</td>
|
||||
</tr>
|
||||
{# Legacy contact fields #}
|
||||
{% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %}
|
||||
{% if object.contact_name %}
|
||||
<tr>
|
||||
<th scope="row">Contact Name</th>
|
||||
<td>
|
||||
{% if object.contact_name %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ object.contact_name|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if object.contact_phone %}
|
||||
<tr>
|
||||
<th scope="row">Contact Phone</th>
|
||||
<td>
|
||||
{% if object.contact_phone %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
<a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if object.contact_email %}
|
||||
<tr>
|
||||
<th scope="row">Contact E-Mail</th>
|
||||
<td>
|
||||
{% if object.contact_email %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
<a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% with deprecation_warning="This field will be removed in NetBox v3.2. Please migrate this data to contact objects." %}
|
||||
<tr>
|
||||
<th scope="row">Contact Name</th>
|
||||
<td>
|
||||
{% if object.contact_name %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ object.contact_name|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Contact Phone</th>
|
||||
<td>
|
||||
{% if object.contact_phone %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
<a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Contact E-Mail</th>
|
||||
<td>
|
||||
{% if object.contact_email %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
|
||||
</div>
|
||||
<a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
</table>
|
||||
</div>
|
||||
@@ -183,44 +184,101 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Stats</h5>
|
||||
<h5 class="card-header">Related Objects</h5>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}" class="btn {% if stats.location_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.location_count }}</a></h2>
|
||||
<p>Locations</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
|
||||
<p>Racks</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
||||
<p>Devices</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}" class="btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
|
||||
<p>Prefixes</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
|
||||
<p>VLANs</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
|
||||
<p>Circuits</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vm_count }}</a></h2>
|
||||
<p>Virtual Machines</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}" class="btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
|
||||
<p>ASNs</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Locations</th>
|
||||
<td class="text-end">
|
||||
{% if stats.location_count %}
|
||||
<a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}">{{ stats.location_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Racks</th>
|
||||
<td class="text-end">
|
||||
{% if stats.rack_count %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ stats.rack_count }}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}">View Racks</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rack_elevation_list' %}?site_id={{ object.pk }}">View Elevations</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Devices</th>
|
||||
<td class="text-end">
|
||||
{% if stats.device_count %}
|
||||
<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}">{{ stats.device_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Virtual Machines</th>
|
||||
<td class="text-end">
|
||||
{% if stats.vm_count %}
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}">{{ stats.vm_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Prefixes</th>
|
||||
<td class="text-end">
|
||||
{% if stats.prefix_count %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}">{{ stats.prefix_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">VLANs</th>
|
||||
<td class="text-end">
|
||||
{% if stats.vlan_count %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}">{{ stats.vlan_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">ASNs</th>
|
||||
<td class="text-end">
|
||||
{% if stats.asn_count %}
|
||||
<a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}">{{ stats.asn_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Circuits</th>
|
||||
<td class="text-end">
|
||||
{% if stats.circuit_count %}
|
||||
<a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}">{{ stats.circuit_count }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Locations</h5>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{% extends '500.html' %}
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
A module import error occurred during this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
|
||||
missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
|
||||
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
|
||||
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
|
||||
required packages.
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
|
||||
has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
|
||||
ensures that the new code is running.
|
||||
</p>
|
||||
<p>
|
||||
A module import error occurred during this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
|
||||
missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
|
||||
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process. To
|
||||
verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
|
||||
required packages.
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has
|
||||
recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that
|
||||
the new code is running.
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends '500.html' %}
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
A file permission error was detected while processing this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Insufficient write permission to the media root</strong> - The configured
|
||||
media root is <code>{{ settings.MEDIA_ROOT }}</code>. Ensure that the user NetBox runs as has access to write
|
||||
files to all locations within this path.
|
||||
</p>
|
||||
<p>
|
||||
A file permission error was detected while processing this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Insufficient write permission to the media root</strong> - The configured
|
||||
media root is <code>{{ settings.MEDIA_ROOT }}</code>. Ensure that the user NetBox runs as has access to write files
|
||||
to all locations within this path.
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{% extends '500.html' %}
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
A database programming error was detected while processing this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Database migrations missing</strong> - When upgrading to a new NetBox release, the upgrade script must
|
||||
be run to apply any new database migrations. You can run migrations manually by executing
|
||||
<code>python3 manage.py migrate</code> from the command line.
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.6 or higher is in use. You
|
||||
can check this by connecting to the database using NetBox's credentials and issuing a query for
|
||||
<code>SELECT VERSION()</code>.
|
||||
</p>
|
||||
<p>
|
||||
A database programming error was detected while processing this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Database migrations missing</strong> - When upgrading to a new NetBox release,
|
||||
the upgrade script must be run to apply any new database migrations. You can run migrations manually by executing
|
||||
<code>python3 manage.py migrate</code> from the command line.
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 10
|
||||
or later is in use. You can check this by connecting to the database using NetBox's credentials and issuing a query
|
||||
for <code>SELECT VERSION()</code>.
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -38,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>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{% for class_name, script in module_scripts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
|
||||
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% include 'extras/inc/job_label.html' with result=script.result %}
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
<div class="noprint bulk-buttons">
|
||||
<div class="bulk-button-group">
|
||||
{% if perms.ipam.change_prefix %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_prefix %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
|
||||
{% block title %}Search{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" type="button" role="tab">
|
||||
Results
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block content-wrapper %}
|
||||
<div class="tab-content">
|
||||
{% if request.GET.q %}
|
||||
{% if results %}
|
||||
<div class="row">
|
||||
@@ -73,4 +84,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
</div>
|
||||
{% endblock content-wrapper %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user