mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-30 00:57:46 -06:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26abd1f4e2 | ||
|
|
918ca4fdcb | ||
|
|
24e5dd0c78 | ||
|
|
f35715683e | ||
|
|
b5a65bc66c | ||
|
|
3a21f7ab26 | ||
|
|
a2c012d2c4 | ||
|
|
4896059162 | ||
|
|
eadecf3eda | ||
|
|
39e4ab164e | ||
|
|
5089df3ffd | ||
|
|
9738257aa2 | ||
|
|
693b32077a | ||
|
|
47ea75348c | ||
|
|
cd9c425d9a | ||
|
|
6612e0107e | ||
|
|
ae0a481c64 | ||
|
|
1a43ef82d9 | ||
|
|
f2bc824a8e | ||
|
|
23a9cb2fad | ||
|
|
22a80c5558 | ||
|
|
14ef7fa869 | ||
|
|
068803dc86 | ||
|
|
ee7f43abe1 | ||
|
|
015e25c618 | ||
|
|
5c8694ad65 | ||
|
|
ff42c89cc4 | ||
|
|
856500b014 | ||
|
|
380f59ac0b | ||
|
|
d30874e0de | ||
|
|
28925c12eb | ||
|
|
31fcad4dbb | ||
|
|
e2a840ff0b | ||
|
|
0a40418614 | ||
|
|
90dbe9bf60 | ||
|
|
09dc271eec | ||
|
|
1f0a4cc548 | ||
|
|
c0b94e4e8e | ||
|
|
e404f4efd2 | ||
|
|
afa1449f89 | ||
|
|
d540728f50 | ||
|
|
12402f4c30 | ||
|
|
2bc524a2ee | ||
|
|
43f1fbf5b3 | ||
|
|
e983f44fd3 | ||
|
|
4d9da4a1f8 | ||
|
|
9d30712fb2 | ||
|
|
03b207d154 | ||
|
|
df6ad680ce | ||
|
|
b1b63513e7 | ||
|
|
22e30b93d5 | ||
|
|
c5e82a3895 | ||
|
|
4466458076 | ||
|
|
47a6fc19ca | ||
|
|
c891f43b14 | ||
|
|
1509650462 | ||
|
|
695e9ec5d7 | ||
|
|
0c8d45f679 | ||
|
|
d0ac4332ab | ||
|
|
08b9eedcec | ||
|
|
5f9e687c9c | ||
|
|
f1877fcea9 | ||
|
|
78d104e60c | ||
|
|
dd0185816c | ||
|
|
83eede8bc5 | ||
|
|
b22d4cb9ca | ||
|
|
de081d0205 | ||
|
|
f92569d468 | ||
|
|
bbbfc27593 | ||
|
|
d289b26034 | ||
|
|
babdc1db38 | ||
|
|
b559c827d2 | ||
|
|
268aa755c4 | ||
|
|
5d7935c855 | ||
|
|
6305a35a4f | ||
|
|
a0103036e4 | ||
|
|
b3b7e08c50 | ||
|
|
7dbeaf7a01 | ||
|
|
967073eaaf | ||
|
|
473d76c9d1 | ||
|
|
5bff50cade | ||
|
|
bfcbd9da6c | ||
|
|
2435c177f5 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,5 +5,5 @@ contact_links:
|
||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||
about: Please read through our contributing policy before opening an issue or pull request
|
||||
- name: 💬 Discussion Group
|
||||
url: https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
url: https://groups.google.com/g/netbox-discuss
|
||||
about: Join our discussion group for assistance with installation issues and other problems
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
|
||||
If you have a general idea or question, please post to our mailing list
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
|
||||
NOTE: Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which significantly extend NetBox's feature scope.
|
||||
@@ -21,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
|
||||
before submitting a bug report.
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.6.9 -->
|
||||
* NetBox version: <!-- Example: 2.7.3 -->
|
||||
* Python version:
|
||||
* NetBox version:
|
||||
|
||||
<!--
|
||||
Describe in detail the new functionality you are proposing. Include any
|
||||
|
||||
@@ -8,7 +8,7 @@ except to report bugs or request features.
|
||||
|
||||
We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
|
||||
|
||||
### Slack
|
||||
|
||||
@@ -164,7 +164,7 @@ overlooked.
|
||||
* Official channels for communication include:
|
||||
|
||||
* GitHub issues/pull requests
|
||||
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
|
||||
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
|
||||
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
|
||||
|
||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
||||
|
||||
@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
|
||||
|
||||
If you are interested in contributing to the development of NetBox, please read
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
@@ -17,6 +17,18 @@ When viewing a device named Router4, this link would render as:
|
||||
|
||||
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `obj` | The NetBox object being displayed |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -231,6 +231,30 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
* `min_prefix_length` - Minimum length of the mask
|
||||
* `max_prefix_length` - Maximum length of the mask
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
|
||||
|
||||
### Via the API
|
||||
|
||||
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
http://netbox/api/extras/scripts/example.MyReport/ \
|
||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
|
||||
|
||||
@@ -101,11 +101,14 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
|
||||
|
||||
Once a report has been run, its associated results will be included in the report view.
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
|
||||
|
||||
### Via the API
|
||||
|
||||
|
||||
@@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi
|
||||
|
||||
---
|
||||
|
||||
## RQ_DEFAULT_TIMEOUT
|
||||
|
||||
Default: `300`
|
||||
|
||||
The maximum execution time of a background task (such as running a custom script), in seconds.
|
||||
|
||||
---
|
||||
|
||||
## SCRIPTS_ROOT
|
||||
|
||||
Default: `$INSTALL_ROOT/netbox/scripts/`
|
||||
|
||||
@@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
|
||||
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
|
||||
* `PASSWORD` - Redis password (if set)
|
||||
* `DATABASE` - Numeric database ID
|
||||
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
|
||||
* `SSL` - Use SSL connection to Redis
|
||||
|
||||
An example configuration is provided below:
|
||||
@@ -77,7 +76,6 @@ REDIS = {
|
||||
'PORT': 1234,
|
||||
'PASSWORD': 'foobar',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -85,7 +83,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@@ -109,6 +106,7 @@ above and the addition of two new keys.
|
||||
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
|
||||
of the Redis server and port for each sentinel instance to connect to
|
||||
* `SENTINEL_SERVICE`: Name of the master / service to connect to
|
||||
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
|
||||
|
||||
Example:
|
||||
|
||||
@@ -117,9 +115,9 @@ REDIS = {
|
||||
'tasks': {
|
||||
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'SENTINEL_TIMEOUT': 10,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -130,7 +128,6 @@ REDIS = {
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
Communication among developers should always occur via public channels:
|
||||
|
||||
* [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 an issue.
|
||||
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
|
||||
## Governance
|
||||
|
||||
@@ -89,7 +89,3 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
|
||||
```
|
||||
VERSION = 'v2.3.5-dev'
|
||||
```
|
||||
|
||||
### Announce the Release
|
||||
|
||||
Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.
|
||||
|
||||
@@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
|
||||
```no-highlight
|
||||
# pip install --upgrade pip
|
||||
# pip3 install --upgrade pip
|
||||
```
|
||||
|
||||
## Download NetBox
|
||||
@@ -163,7 +163,6 @@ REDIS = {
|
||||
'PORT': 6379, # Redis port
|
||||
'PASSWORD': '', # Redis password (optional)
|
||||
'DATABASE': 0, # Database ID
|
||||
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
|
||||
'SSL': False, # Use SSL (optional)
|
||||
},
|
||||
'caching': {
|
||||
@@ -171,7 +170,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1, # Unique ID for second database
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
||||
BIN
docs/media/admin_ui_run_permission.png
Normal file
BIN
docs/media/admin_ui_run_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -1,3 +1,3 @@
|
||||
## Rear Port Templates
|
||||
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024).
|
||||
|
||||
@@ -12,6 +12,9 @@ Plugins can do a lot, including:
|
||||
|
||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||
|
||||
!!! warning
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
## Plugin Structure
|
||||
@@ -328,6 +331,9 @@ A `PluginMenuButton` has the following attributes:
|
||||
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
|
||||
* `permissions` - A list of permissions required to display this button (optional)
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
|
||||
## Extending Core Templates
|
||||
|
||||
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.5 (2020-10-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
|
||||
* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
|
||||
* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
|
||||
* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
|
||||
* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
|
||||
* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
|
||||
* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
|
||||
* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
|
||||
* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
|
||||
* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
|
||||
* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
|
||||
* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type
|
||||
* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
|
||||
* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
|
||||
* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
|
||||
|
||||
---
|
||||
|
||||
## v2.9.4 (2020-09-23)
|
||||
|
||||
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
|
||||
|
||||
**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
|
||||
* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
|
||||
* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
|
||||
* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
|
||||
* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
|
||||
* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
|
||||
* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
|
||||
* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
|
||||
* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
|
||||
* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
|
||||
* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
|
||||
* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
|
||||
* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
|
||||
* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
|
||||
* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
|
||||
* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
|
||||
* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
|
||||
|
||||
---
|
||||
|
||||
## v2.9.3 (2020-09-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
|
||||
* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
|
||||
* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
|
||||
* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
|
||||
* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
|
||||
* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
|
||||
* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
|
||||
* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
|
||||
* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
|
||||
* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
|
||||
* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
|
||||
* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
|
||||
* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
|
||||
* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
|
||||
|
||||
---
|
||||
|
||||
## v2.9.2 (2020-08-27)
|
||||
|
||||
### Enhancements
|
||||
@@ -96,6 +179,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
|
||||
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
|
||||
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
|
||||
|
||||
### REST API Changes
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import Graph
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
@@ -45,7 +44,7 @@ class ProviderView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
|
||||
@@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
class PortTypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_8P8C = '8p8c'
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
@@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
'Copper',
|
||||
(
|
||||
(TYPE_8P8C, '8P8C'),
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
|
||||
@@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
#
|
||||
|
||||
REARPORT_POSITIONS_MIN = 1
|
||||
REARPORT_POSITIONS_MAX = 64
|
||||
REARPORT_POSITIONS_MAX = 1024
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -149,7 +149,7 @@ class RackElevationSVG:
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device']:
|
||||
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
||||
@@ -662,16 +662,10 @@ class DeviceFilterSet(
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
else:
|
||||
return queryset.exclude(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
def _virtual_chassis_member(self, queryset, name, value):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
|
||||
@@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
'site_id': '$site',
|
||||
'group_id': '$rack_group',
|
||||
}
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
@@ -2317,7 +2326,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'device', 'name', 'type', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2390,7 +2399,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2479,7 +2488,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@@ -2835,6 +2844,24 @@ class InterfaceBulkEditForm(
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
|
||||
else:
|
||||
# See 4523
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
if site is None:
|
||||
site = interface.device.site
|
||||
elif interface.device.site is not site:
|
||||
site = None
|
||||
break
|
||||
|
||||
if site is not None:
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
|
||||
self.fields['lag'].choices = ()
|
||||
self.fields['lag'].widget.attrs['disabled'] = True
|
||||
|
||||
|
||||
34
netbox/dcim/migrations/0116_rearport_max_positions.py
Normal file
34
netbox/dcim/migrations/0116_rearport_max_positions.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.1 on 2020-09-16 16:51
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0115_rackreservation_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='frontport',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='frontporttemplate',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearport',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearporttemplate',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
]
|
||||
@@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -405,6 +405,14 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
self.description,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
raise ValidationError({
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
@@ -809,7 +817,10 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -840,17 +851,16 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
raise ValidationError(
|
||||
"Rear port ({}) must belong to the same device".format(self.rear_port)
|
||||
)
|
||||
raise ValidationError({
|
||||
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
|
||||
})
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError(
|
||||
"Invalid rear port position ({}); rear port {} has only {} positions".format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
)
|
||||
)
|
||||
raise ValidationError({
|
||||
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
|
||||
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
|
||||
})
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@@ -864,7 +874,10 @@ class RearPort(CableTermination, ComponentModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -877,6 +890,16 @@ class RearPort(CableTermination, ComponentModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that positions count is greater than or equal to the number of associated FrontPorts
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": f"The number of positions cannot be less than the number of mapped front ports "
|
||||
f"({frontport_count})"
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
|
||||
@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
CONNECTION_STATUS = """
|
||||
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
@@ -908,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
|
||||
verbose_name='Console Server'
|
||||
)
|
||||
connected_endpoint = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Port'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Console Port'
|
||||
)
|
||||
connection_status = BooleanColumn()
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePort
|
||||
@@ -933,14 +942,20 @@ class PowerConnectionTable(BaseTable):
|
||||
)
|
||||
outlet = tables.Column(
|
||||
accessor=Accessor('_connected_poweroutlet'),
|
||||
linkify=True,
|
||||
verbose_name='Outlet'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Power Port'
|
||||
)
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
@@ -972,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
|
||||
args=[Accessor('_connected_interface__pk')],
|
||||
verbose_name='Interface B'
|
||||
)
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
@@ -20,7 +19,7 @@ from ipam.models import IPAddress, Prefix, Service, VLAN
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from secrets.models import Secret
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import csv_format, get_subquery
|
||||
from utilities.views import (
|
||||
@@ -169,9 +168,13 @@ class SiteView(ObjectView):
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
|
||||
}
|
||||
rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
rack_groups = RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
Rack,
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=site)
|
||||
show_graphs = Graph.objects.filter(type__model='site').exists()
|
||||
|
||||
return render(request, 'dcim/site.html', {
|
||||
@@ -310,8 +313,13 @@ class RackElevationListView(ObjectListView):
|
||||
racks = filters.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Determine ordering
|
||||
reverse = bool(request.GET.get('reverse', False))
|
||||
if reverse:
|
||||
racks = racks.reverse()
|
||||
|
||||
# Pagination
|
||||
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
per_page = get_paginate_count(request)
|
||||
page_number = request.GET.get('page', 1)
|
||||
paginator = EnhancedPaginator(racks, per_page)
|
||||
try:
|
||||
@@ -330,6 +338,7 @@ class RackElevationListView(ObjectListView):
|
||||
'paginator': paginator,
|
||||
'page': page,
|
||||
'total_count': total_count,
|
||||
'reverse': reverse,
|
||||
'rack_face': rack_face,
|
||||
'filter_form': forms.RackElevationFilterForm(request.GET),
|
||||
})
|
||||
@@ -408,7 +417,6 @@ class RackReservationListView(ObjectListView):
|
||||
filterset = filters.RackReservationFilterSet
|
||||
filterset_form = forms.RackReservationFilterForm
|
||||
table = tables.RackReservationTable
|
||||
action_buttons = ('export',)
|
||||
|
||||
|
||||
class RackReservationView(ObjectView):
|
||||
@@ -1033,7 +1041,7 @@ class DeviceView(ObjectView):
|
||||
)
|
||||
|
||||
# Interfaces
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
|
||||
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
|
||||
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
|
||||
@@ -1233,6 +1241,7 @@ class ConsolePortCreateView(ComponentCreateView):
|
||||
class ConsolePortEditView(ObjectEditView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
model_form = forms.ConsolePortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsolePortDeleteView(ObjectDeleteView):
|
||||
@@ -1292,6 +1301,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
|
||||
class ConsoleServerPortEditView(ObjectEditView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsoleServerPortDeleteView(ObjectDeleteView):
|
||||
@@ -1351,6 +1361,7 @@ class PowerPortCreateView(ComponentCreateView):
|
||||
class PowerPortEditView(ObjectEditView):
|
||||
queryset = PowerPort.objects.all()
|
||||
model_form = forms.PowerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerPortDeleteView(ObjectDeleteView):
|
||||
@@ -1410,6 +1421,7 @@ class PowerOutletCreateView(ComponentCreateView):
|
||||
class PowerOutletEditView(ObjectEditView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
model_form = forms.PowerOutletForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerOutletDeleteView(ObjectDeleteView):
|
||||
@@ -1561,6 +1573,7 @@ class FrontPortCreateView(ComponentCreateView):
|
||||
class FrontPortEditView(ObjectEditView):
|
||||
queryset = FrontPort.objects.all()
|
||||
model_form = forms.FrontPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class FrontPortDeleteView(ObjectDeleteView):
|
||||
@@ -1620,6 +1633,7 @@ class RearPortCreateView(ComponentCreateView):
|
||||
class RearPortEditView(ObjectEditView):
|
||||
queryset = RearPort.objects.all()
|
||||
model_form = forms.RearPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class RearPortDeleteView(ObjectDeleteView):
|
||||
@@ -1679,6 +1693,7 @@ class DeviceBayCreateView(ComponentCreateView):
|
||||
class DeviceBayEditView(ObjectEditView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
model_form = forms.DeviceBayForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class DeviceBayDeleteView(ObjectDeleteView):
|
||||
|
||||
@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
value = instance.cf.get(field.name)
|
||||
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
|
||||
if field.type == CustomFieldTypeChoices.TYPE_SELECT and type(value) is CustomFieldChoice:
|
||||
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
|
||||
else:
|
||||
instance.custom_fields[field.name] = value
|
||||
|
||||
@@ -101,24 +101,30 @@ class TaggedObjectSerializer(serializers.Serializer):
|
||||
tags = NestedTagSerializer(many=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
tags = validated_data.pop('tags', None)
|
||||
instance = super().create(validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# Cache tags on instance for change logging
|
||||
instance._tags = tags
|
||||
instance._tags = tags or []
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set(*[t.name for t in tags])
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ class ImageAttachmentViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
filterset_class = filters.ImageAttachmentFilterSet
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
@@ -7,7 +8,7 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.filters import BaseFilterSet
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
|
||||
from .models import ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange, Tag
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -17,6 +18,7 @@ __all__ = (
|
||||
'CustomFieldFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'GraphFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'TagFilterSet',
|
||||
@@ -104,6 +106,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
|
||||
fields = ['id', 'content_type', 'name', 'template_language']
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ImageAttachment
|
||||
fields = ['id', 'content_type', 'object_id', 'name']
|
||||
|
||||
|
||||
class TagFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -251,12 +260,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
label='Search',
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label='User (ID)',
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label='User name',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
fields = [
|
||||
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
|
||||
'object_repr',
|
||||
'id', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
|
||||
@@ -397,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
user = DynamicModelMultipleChoiceField(
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
display_field='username',
|
||||
label='User',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import time
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from extras.reports import get_reports
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from extras.models import JobResult
|
||||
from extras.reports import get_reports, run_report
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -20,15 +25,33 @@ class Command(BaseCommand):
|
||||
for report in report_list:
|
||||
if module_name in options['reports'] or report.full_name in options['reports']:
|
||||
|
||||
# Run the report and create a new ReportResult
|
||||
# Run the report and create a new JobResult
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||
)
|
||||
report.run()
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
None
|
||||
)
|
||||
|
||||
# Wait on the job to finish
|
||||
while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
time.sleep(1)
|
||||
job_result = JobResult.objects.get(pk=job_result.pk)
|
||||
|
||||
# Report on success/failure
|
||||
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
|
||||
for test_name, attrs in report.result.data.items():
|
||||
if job_result.status == JobResultStatusChoices.STATUS_FAILED:
|
||||
status = self.style.ERROR('FAILED')
|
||||
elif job_result == JobResultStatusChoices.STATUS_ERRORED:
|
||||
status = self.style.ERROR('ERRORED')
|
||||
else:
|
||||
status = self.style.SUCCESS('SUCCESS')
|
||||
|
||||
for test_name, attrs in job_result.data.items():
|
||||
self.stdout.write(
|
||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||
@@ -37,6 +60,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||
)
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
|
||||
)
|
||||
|
||||
# Wrap things up
|
||||
self.stdout.write(
|
||||
|
||||
@@ -200,15 +200,14 @@ class CustomField(models.Model):
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
|
||||
if not required:
|
||||
if not required or default_choice is None:
|
||||
choices = add_blank_choice(choices)
|
||||
|
||||
# Set the initial value to the PK of the default choice, if any
|
||||
if set_initial:
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
if default_choice:
|
||||
initial = default_choice.pk
|
||||
if set_initial and default_choice:
|
||||
initial = default_choice.pk
|
||||
|
||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||
field = field_class(
|
||||
|
||||
@@ -35,10 +35,8 @@ OBJECTCHANGE_ACTION = """
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.action != 3 and record.changed_object.get_absolute_url %}
|
||||
{% if record.changed_object.get_absolute_url %}
|
||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% elif record.action != 3 and record.related_object.get_absolute_url %}
|
||||
<a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% else %}
|
||||
{{ record.object_repr }}
|
||||
{% endif %}
|
||||
|
||||
@@ -20,8 +20,8 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
|
||||
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def custom_links(obj):
|
||||
@register.simple_tag(takes_context=True)
|
||||
def custom_links(context, obj):
|
||||
"""
|
||||
Render all applicable links for the given object.
|
||||
"""
|
||||
@@ -30,8 +30,13 @@ def custom_links(obj):
|
||||
if not custom_links:
|
||||
return ''
|
||||
|
||||
context = {
|
||||
# Pass select context data when rendering the CustomLink
|
||||
link_context = {
|
||||
'obj': obj,
|
||||
'debug': context['debug'], # django.template.context_processors.debug
|
||||
'request': context['request'], # django.template.context_processors.request
|
||||
'user': context['user'], # django.contrib.auth.context_processors.auth
|
||||
'perms': context['perms'], # django.contrib.auth.context_processors.auth
|
||||
}
|
||||
template_code = ''
|
||||
group_names = OrderedDict()
|
||||
@@ -47,9 +52,9 @@ def custom_links(obj):
|
||||
# Add non-grouped links
|
||||
else:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
text_rendered = render_jinja2(cl.text, link_context)
|
||||
if text_rendered:
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
link_rendered = render_jinja2(cl.url, link_context)
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
link_rendered, link_target, cl.button_class, text_rendered
|
||||
@@ -65,10 +70,10 @@ def custom_links(obj):
|
||||
|
||||
for cl in links:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
text_rendered = render_jinja2(cl.text, link_context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
link_rendered = render_jinja2(cl.url, link_context)
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(link_rendered, link_target, text_rendered)
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from dcim.models import DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.filters import *
|
||||
from extras.utils import FeatureQuery
|
||||
from extras.models import ConfigContext, ExportTemplate, Graph, Tag
|
||||
from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ImageAttachmentTestCase(TestCase):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
filterset = ImageAttachmentFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get(app_label='dcim', model='site')
|
||||
rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0]),
|
||||
Rack(name='Rack 2', site=sites[1]),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
image_attachments = (
|
||||
ImageAttachment(
|
||||
content_type=site_ct,
|
||||
object_id=sites[0].pk,
|
||||
name='Image Attachment 1',
|
||||
image='http://example.com/image1.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=site_ct,
|
||||
object_id=sites[1].pk,
|
||||
name='Image Attachment 2',
|
||||
image='http://example.com/image2.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=rack_ct,
|
||||
object_id=racks[0].pk,
|
||||
name='Image Attachment 3',
|
||||
image='http://example.com/image3.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=rack_ct,
|
||||
object_id=racks[1].pk,
|
||||
name='Image Attachment 4',
|
||||
image='http://example.com/image4.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
)
|
||||
)
|
||||
ImageAttachment.objects.bulk_create(image_attachments)
|
||||
|
||||
def test_id(self):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type_and_object_id(self):
|
||||
params = {
|
||||
'content_type': ContentType.objects.get(app_label='dcim', model='site').pk,
|
||||
'object_id': [Site.objects.first().pk],
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = ConfigContextFilterSet
|
||||
|
||||
@@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
|
||||
sorted([t.name for t in site.tags.all()]),
|
||||
sorted(["Foo", "Bar", "New Tag"])
|
||||
)
|
||||
|
||||
def test_clear_tagged_item(self):
|
||||
site = Site.objects.create(
|
||||
name='Test Site',
|
||||
slug='test-site'
|
||||
)
|
||||
site.tags.add("Foo", "Bar", "Baz")
|
||||
data = {
|
||||
'tags': []
|
||||
}
|
||||
self.add_permissions('dcim.change_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data['tags']), 0)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(len(site.tags.all()), 0)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
@@ -13,7 +12,7 @@ from rq import Worker
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import copy_safe_request, shallow_compare_dict
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
|
||||
# Apply the request context
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(objectchanges_table)
|
||||
|
||||
@@ -315,7 +314,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@@ -347,7 +346,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
Display a single Report and its associated JobResult (if any).
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
|
||||
@@ -219,7 +219,8 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
|
||||
assigned_object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
|
||||
required=False
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
|
||||
@@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
@@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set assigned object
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
if interface:
|
||||
self.instance.assigned_object = interface
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
interface = self.instance.assigned_object
|
||||
if interface and self.cleaned_data['primary_for_parent']:
|
||||
if ipaddress.address.version == 4:
|
||||
interface.parent.primary_ip4 = ipaddress
|
||||
else:
|
||||
interface.primary_ip6 = ipaddress
|
||||
interface.parent.primary_ip6 = ipaddress
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.primary_ip6 = None
|
||||
interface.parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
@@ -726,30 +726,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk and type(self.assigned_object) is Interface:
|
||||
if self.pk:
|
||||
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if device:
|
||||
if self.assigned_object is None:
|
||||
if getattr(self.assigned_object, 'device', None) != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but not assigned to an interface"
|
||||
'interface': f"IP address is primary for device {device} but not assigned to it!"
|
||||
})
|
||||
elif self.assigned_object.device != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but assigned to "
|
||||
f"{self.assigned_object.device} ({self.assigned_object})"
|
||||
})
|
||||
elif self.pk and type(self.assigned_object) is VMInterface:
|
||||
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if vm:
|
||||
if self.assigned_object is None:
|
||||
if getattr(self.assigned_object, 'virtual_machine', None) != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
|
||||
f"interface"
|
||||
})
|
||||
elif self.assigned_object.virtual_machine != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
|
||||
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
@@ -997,13 +985,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP[self.status]
|
||||
|
||||
def get_members(self):
|
||||
# Return all interfaces assigned to this VLAN
|
||||
def get_interfaces(self):
|
||||
# Return all device interfaces assigned to this VLAN
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
).distinct()
|
||||
|
||||
def get_vminterfaces(self):
|
||||
# Return all VM interfaces assigned to this VLAN
|
||||
return VMInterface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
).distinct()
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Service(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
@@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
|
||||
from virtualization.models import VMInterface
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
RIR_UTILIZATION = """
|
||||
@@ -67,11 +68,7 @@ IPADDRESS_LINK = """
|
||||
"""
|
||||
|
||||
IPADDRESS_ASSIGN_LINK = """
|
||||
{% if request.GET %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
"""
|
||||
|
||||
VRF_LINK = """
|
||||
@@ -103,7 +100,7 @@ VLAN_LINK = """
|
||||
"""
|
||||
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.unrestricted %}
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
@@ -128,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
|
||||
{% endwith %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_UNTAGGED = """
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
<i class="glyphicon glyphicon-ok">
|
||||
<span class="text-danger"><i class="fa fa-close"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="fa fa-check"></i></span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -419,6 +418,10 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
assigned = BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:ipaddress_list'
|
||||
)
|
||||
@@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable):
|
||||
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(
|
||||
order_by=['device', 'virtual_machine']
|
||||
)
|
||||
class VLANMembersTable(BaseTable):
|
||||
"""
|
||||
Base table for Interface and VMInterface assignments
|
||||
"""
|
||||
name = tables.LinkColumn(
|
||||
verbose_name='Interface'
|
||||
)
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
tagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_TAGGED,
|
||||
orderable=False
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable):
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
|
||||
class VLANDevicesTable(VLANMembersTable):
|
||||
device = tables.LinkColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('parent', 'name', 'untagged', 'actions')
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
virtual_machine = tables.LinkColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = ('virtual_machine', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class InterfaceVLANTable(BaseTable):
|
||||
|
||||
@@ -90,7 +90,8 @@ urlpatterns = [
|
||||
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
|
||||
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
|
||||
path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
|
||||
path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
|
||||
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.views import (
|
||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
|
||||
@@ -233,7 +233,7 @@ class AggregateView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
@@ -391,7 +391,7 @@ class PrefixPrefixesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
@@ -435,7 +435,7 @@ class PrefixIPAddressesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(ip_table)
|
||||
|
||||
@@ -527,7 +527,8 @@ class IPAddressView(ObjectView):
|
||||
# Exclude anycast IPs if this IP is anycast
|
||||
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
|
||||
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
|
||||
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
|
||||
# Limit to a maximum of 10 duplicates displayed here
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
|
||||
@@ -539,7 +540,7 @@ class IPAddressView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(related_ips_table)
|
||||
|
||||
@@ -547,6 +548,7 @@ class IPAddressView(ObjectView):
|
||||
'ipaddress': ipaddress,
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'more_duplicate_ips': duplicate_ips.count() > 10,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
|
||||
@@ -582,7 +584,7 @@ class IPAddressAssignView(ObjectView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
# Redirect user if an interface has not been provided
|
||||
if 'interface' not in request.GET:
|
||||
if 'interface' not in request.GET and 'vminterface' not in request.GET:
|
||||
return redirect('ipam:ipaddress_add')
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@@ -609,7 +611,7 @@ class IPAddressAssignView(ObjectView):
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'form': form,
|
||||
'table': table,
|
||||
'return_url': request.GET.get('return_url', ''),
|
||||
'return_url': request.GET.get('return_url'),
|
||||
})
|
||||
|
||||
|
||||
@@ -699,7 +701,7 @@ class VLANGroupVLANsView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request),
|
||||
}
|
||||
RequestConfig(request, paginate).configure(vlan_table)
|
||||
|
||||
@@ -713,6 +715,7 @@ class VLANGroupVLANsView(ObjectView):
|
||||
return render(request, 'ipam/vlangroup_vlans.html', {
|
||||
'vlan_group': vlan_group,
|
||||
'first_available_vlan': vlan_group.get_next_available_vid(),
|
||||
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
|
||||
'vlan_table': vlan_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
@@ -749,26 +752,45 @@ class VLANView(ObjectView):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(ObjectView):
|
||||
class VLANInterfacesView(ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
|
||||
|
||||
members_table = tables.VLANMemberTable(members)
|
||||
interfaces = vlan.get_interfaces().prefetch_related('device')
|
||||
members_table = tables.VLANDevicesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_members.html', {
|
||||
return render(request, 'ipam/vlan_interfaces.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
'active_tab': 'members',
|
||||
'active_tab': 'interfaces',
|
||||
})
|
||||
|
||||
|
||||
class VLANVMInterfacesView(ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
|
||||
members_table = tables.VLANVirtualMachinesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_vminterfaces.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
'active_tab': 'vminterfaces',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -175,6 +175,6 @@ class LDAPBackend:
|
||||
# Enable logging for django_auth_ldap
|
||||
ldap_logger = logging.getLogger('django_auth_ldap')
|
||||
ldap_logger.addHandler(logging.StreamHandler())
|
||||
ldap_logger.setLevel(logging.DEBUG)
|
||||
ldap_logger.setLevel(logging.INFO if settings.DEBUG is False else logging.DEBUG)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -33,7 +33,6 @@ REDIS = {
|
||||
# 'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -44,7 +43,6 @@ REDIS = {
|
||||
# 'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
|
||||
# this setting is derived from the installed location.
|
||||
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
|
||||
|
||||
# Maximum execution time for background tasks, in seconds.
|
||||
RQ_DEFAULT_TIMEOUT = 300
|
||||
|
||||
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
@@ -24,7 +24,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@@ -32,7 +31,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.9.2'
|
||||
VERSION = '2.9.5'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
@@ -220,10 +221,13 @@ TASKS_REDIS_USING_SENTINEL = all([
|
||||
len(TASKS_REDIS_SENTINELS) > 0
|
||||
])
|
||||
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
|
||||
TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
|
||||
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
|
||||
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||
TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
||||
# TODO: Remove in v2.10 (see #5171)
|
||||
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
|
||||
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
|
||||
|
||||
# Caching
|
||||
if 'caching' not in REDIS:
|
||||
@@ -241,7 +245,6 @@ CACHING_REDIS_USING_SENTINEL = all([
|
||||
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
|
||||
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
|
||||
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
|
||||
CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
|
||||
|
||||
|
||||
@@ -549,7 +552,7 @@ if TASKS_REDIS_USING_SENTINEL:
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SOCKET_TIMEOUT': None,
|
||||
'CONNECTION_KWARGS': {
|
||||
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
|
||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||
},
|
||||
}
|
||||
else:
|
||||
@@ -558,8 +561,8 @@ else:
|
||||
'PORT': TASKS_REDIS_PORT,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
Python version: {{ python_version }}
|
||||
NetBox version: {{ netbox_version }}</pre>
|
||||
<p>
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
|
||||
</p>
|
||||
<div class="text-right">
|
||||
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
|
||||
|
||||
@@ -11,11 +11,8 @@
|
||||
<div class="row noprint">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
|
||||
{% if device.rack %}
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
|
||||
<li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
|
||||
{% if device.parent_bay %}
|
||||
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
|
||||
<li>{{ device.parent_bay }}</li>
|
||||
@@ -101,7 +98,7 @@
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">
|
||||
Inventory <span class="badge">{{ device.inventoryitems.unrestricted.count }}</span>
|
||||
Inventory <span class="badge">{{ device.inventoryitems.count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.dcim.napalm_read_device %}
|
||||
@@ -151,8 +148,10 @@
|
||||
<td>
|
||||
{% if device.rack %}
|
||||
{% if device.rack.group %}
|
||||
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% for group in device.rack.group.get_ancestors %}
|
||||
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> <i class="fa fa-caret-right"></i>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
|
||||
{% else %}
|
||||
@@ -327,7 +326,7 @@
|
||||
{% plugin_left_page device %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if power_ports and poweroutlets %}
|
||||
{% if powerports and poweroutlets %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Utilization</strong>
|
||||
@@ -340,7 +339,7 @@
|
||||
<th>Available</th>
|
||||
<th>Utilization</th>
|
||||
</tr>
|
||||
{% for pp in power_ports %}
|
||||
{% for pp in powerports %}
|
||||
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
|
||||
<tr>
|
||||
<td>{{ pp }}</td>
|
||||
|
||||
16
netbox/templates/dcim/device_component_edit.html
Normal file
16
netbox/templates/dcim/device_component_edit.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% if form.instance.device %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_form form %}
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack_group %}
|
||||
{% render_field form.rack %}
|
||||
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||
<div class="form-group">
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_consoleserverport %}
|
||||
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_poweroutlet %}
|
||||
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_powerport %}
|
||||
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{% load helpers %}
|
||||
<div class="rack_header">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
|
||||
{% endif %}
|
||||
{% if rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -5,6 +5,16 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% if form.instance.device %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.label %}
|
||||
{% render_field form.type %}
|
||||
@@ -14,6 +24,11 @@
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
|
||||
{% if rack.group %}
|
||||
{% for group in rack.group.get_ancestors %}
|
||||
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ rack }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -87,7 +93,10 @@
|
||||
<td>Group</td>
|
||||
<td>
|
||||
{% if rack.group %}
|
||||
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a>
|
||||
{% for group in rack.group.get_ancestors %}
|
||||
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="btn-group pull-right noprint" role="group">
|
||||
<div class="btn-toolbar pull-right noprint" role="toolbar">
|
||||
<button class="btn btn-default toggle-images" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||
</button>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse=None %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
|
||||
<div class="row">
|
||||
@@ -17,10 +23,23 @@
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% for rack in page %}
|
||||
<div style="display: inline-block; width: 266px">
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
|
||||
{% endif %}
|
||||
{% if rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
|
||||
<div class="clearfix"></div>
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.facility_id %}
|
||||
<small class="text-muted">({{ rack.facility_id }})</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
|
||||
{% if site.region %}
|
||||
{% for region in site.region.get_ancestors.unrestricted %}
|
||||
{% for region in site.region.get_ancestors %}
|
||||
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
|
||||
@@ -86,7 +86,7 @@
|
||||
<td>Region</td>
|
||||
<td>
|
||||
{% if site.region %}
|
||||
{% for region in site.region.get_ancestors.unrestricted %}
|
||||
{% for region in site.region.get_ancestors %}
|
||||
<a href="{{ region.get_absolute_url }}">{{ region }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endfor %}
|
||||
@@ -255,7 +255,7 @@
|
||||
<table class="table table-hover panel-body">
|
||||
{% for rg in rack_groups %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td>{{ rg.rack_count }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
A module import error occurred during this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-warning"></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 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.
|
||||
<i class="fa fa-warning"></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="fa fa-warning"></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.
|
||||
<i class="fa fa-warning"></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 %}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Reports</strong>
|
||||
</div>
|
||||
{% if report_results and perms.extras.view_reportresult %}
|
||||
{% if report_results and perms.extras.view_report %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for result in report_results %}
|
||||
<tr>
|
||||
@@ -285,7 +285,7 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% elif perms.extras.view_reportresult %}
|
||||
{% elif perms.extras.view_report %}
|
||||
<div class="panel-body text-muted">
|
||||
None found
|
||||
</div>
|
||||
|
||||
@@ -518,7 +518,7 @@
|
||||
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:script_list' %}">Scripts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
|
||||
<li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:report_list' %}">Reports</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -5,18 +5,22 @@
|
||||
{% for section_name, menu_items in registry.plugin_menu_items.items %}
|
||||
<li class="dropdown-header">{{ section_name }}</li>
|
||||
{% for menu_item in menu_items %}
|
||||
<li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}>
|
||||
{% if menu_item.buttons %}
|
||||
<div class="buttons pull-right">
|
||||
{% for button in menu_item.buttons %}
|
||||
{% if not button.permissions or request.user|has_perms:button.permissions %}
|
||||
<a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
|
||||
</li>
|
||||
{% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
|
||||
<li>
|
||||
{% if menu_item.buttons %}
|
||||
<div class="buttons pull-right">
|
||||
{% for button in menu_item.buttons %}
|
||||
{% if not button.permissions or request.user|has_perms:button.permissions %}
|
||||
<a href="{% url button.link %}" class="btn btn-xs btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not forloop.last %}
|
||||
<li class="divider"></li>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load custom_links %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
@@ -159,7 +160,24 @@
|
||||
<div class="col-md-8">
|
||||
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
|
||||
{# Custom version of panel_table.html #}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading">
|
||||
<strong>Duplicate IP Addresses</strong>
|
||||
{% if more_duplicate_ips %}
|
||||
<div class="pull-right">
|
||||
<a type="button" class="btn btn-primary btn-xs"
|
||||
{% if ipaddress.vrf %}
|
||||
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id={{ ipaddress.vrf.pk }}"
|
||||
{% else %}
|
||||
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id=null"
|
||||
{% endif %}
|
||||
>Show all</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% render_table duplicate_ips_table 'inc/table.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
|
||||
{% plugin_right_page ipaddress %}
|
||||
|
||||
@@ -52,8 +52,11 @@
|
||||
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
|
||||
<li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{% extends 'ipam/vlan.html' %}
|
||||
|
||||
{% block title %}{{ block.super }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
9
netbox/templates/ipam/vlan_vminterfaces.html
Normal file
9
netbox/templates/ipam/vlan_vminterfaces.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends 'ipam/vlan.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -31,7 +31,9 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
{% block form_fields %}
|
||||
{% render_form form %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
|
||||
{% block title %}Create {{ component_type }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" class="form form-horizontal">
|
||||
|
||||
@@ -5,14 +5,34 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% if form.instance.virtual_machine %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.mac_address %}
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,10 @@ class LoginView(View):
|
||||
def get(self, request):
|
||||
form = LoginForm(request)
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logger = logging.getLogger('netbox.auth.login')
|
||||
return self.redirect_to_next(request, logger)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
})
|
||||
@@ -49,12 +53,6 @@ class LoginView(View):
|
||||
if form.is_valid():
|
||||
logger.debug("Login form validation was successful")
|
||||
|
||||
# Determine where to direct user after successful login
|
||||
redirect_to = request.POST.get('next', reverse('home'))
|
||||
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
|
||||
redirect_to = reverse('home')
|
||||
|
||||
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
|
||||
# last_login time upon authentication.
|
||||
if settings.MAINTENANCE_MODE:
|
||||
@@ -66,8 +64,7 @@ class LoginView(View):
|
||||
logger.info(f"User {request.user} successfully authenticated")
|
||||
messages.info(request, "Logged in as {}.".format(request.user))
|
||||
|
||||
logger.debug(f"Redirecting user to {redirect_to}")
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
return self.redirect_to_next(request, logger)
|
||||
|
||||
else:
|
||||
logger.debug("Login form validation failed")
|
||||
@@ -76,6 +73,19 @@ class LoginView(View):
|
||||
'form': form,
|
||||
})
|
||||
|
||||
def redirect_to_next(self, request, logger):
|
||||
if request.method == "POST":
|
||||
redirect_to = request.POST.get('next', reverse('home'))
|
||||
else:
|
||||
redirect_to = request.GET.get('next', reverse('home'))
|
||||
|
||||
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
|
||||
redirect_to = reverse('home')
|
||||
|
||||
logger.debug(f"Redirecting user to {redirect_to}")
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
|
||||
|
||||
class LogoutView(View):
|
||||
"""
|
||||
|
||||
@@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
|
||||
key = f'data-query-param-{name}'
|
||||
|
||||
values = json.loads(self.attrs.get(key, '[]'))
|
||||
if type(value) is list:
|
||||
if type(value) in (list, tuple):
|
||||
values.extend([str(v) for v in value])
|
||||
else:
|
||||
values.append(str(value))
|
||||
|
||||
@@ -22,7 +22,7 @@ class Command(_Command):
|
||||
"This command is available for development purposes only. It will\n"
|
||||
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
||||
"please post to the NetBox mailing list:\n"
|
||||
" https://groups.google.com/forum/#!forum/netbox-discuss"
|
||||
" https://groups.google.com/g/netbox-discuss"
|
||||
)
|
||||
|
||||
super().handle(*args, **kwargs)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import django_tables2 as tables
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models.fields.related import RelatedField
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -44,7 +45,7 @@ class BaseTable(tables.Table):
|
||||
self.columns.show(name)
|
||||
else:
|
||||
self.columns.hide(name)
|
||||
self.sequence = columns
|
||||
self.sequence = [c for c in columns if c in self.base_columns]
|
||||
|
||||
# Always include PK and actions column, if defined on the table
|
||||
if pk:
|
||||
@@ -63,7 +64,7 @@ class BaseTable(tables.Table):
|
||||
field_path = column.accessor.split('.')
|
||||
try:
|
||||
model_field = model._meta.get_field(field_path[0])
|
||||
if isinstance(model_field, RelatedField):
|
||||
if isinstance(model_field, (RelatedField, GenericForeignKey)):
|
||||
prefetch_fields.append('__'.join(field_path))
|
||||
except FieldDoesNotExist:
|
||||
pass
|
||||
@@ -114,12 +115,12 @@ class BooleanColumn(tables.Column):
|
||||
character.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value is True:
|
||||
if value:
|
||||
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
|
||||
elif value is False:
|
||||
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
|
||||
else:
|
||||
elif value is None:
|
||||
rendered = '<span class="text-muted">—</span>'
|
||||
else:
|
||||
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
|
||||
return mark_safe(rendered)
|
||||
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ class APIViewTestCases:
|
||||
response = self.client.patch(url, update_data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
instance.refresh_from_db()
|
||||
self.assertInstanceEqual(instance, self.update_data, api=True)
|
||||
self.assertInstanceEqual(instance, update_data, api=True)
|
||||
|
||||
class DeleteObjectViewTestCase(APITestCase):
|
||||
|
||||
|
||||
@@ -945,7 +945,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, ManyToManyField):
|
||||
if form.cleaned_data[name].count() > 0:
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
@@ -1352,7 +1352,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
|
||||
for obj in data['pk']:
|
||||
|
||||
names = data['name_pattern']
|
||||
labels = data['label_pattern']
|
||||
labels = data['label_pattern'] if 'label_pattern' in data else None
|
||||
for i, name in enumerate(names):
|
||||
label = labels[i] if labels else None
|
||||
|
||||
|
||||
@@ -186,6 +186,10 @@ class VirtualMachineFilterSet(
|
||||
field_name='interfaces__mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
@@ -200,6 +204,12 @@ class VirtualMachineFilterSet(
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
class VMInterfaceFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -516,6 +516,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
|
||||
required=False,
|
||||
label='MAC address'
|
||||
)
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a primary IP',
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0082_3569_interface_fields'),
|
||||
('ipam', '0037_ipaddress_assignment'),
|
||||
('virtualization', '0015_vminterface'),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.choices import *
|
||||
from virtualization.filters import *
|
||||
@@ -266,6 +267,15 @@ class VirtualMachineTestCase(TestCase):
|
||||
)
|
||||
VMInterface.objects.bulk_create(interfaces)
|
||||
|
||||
# Assign primary IPs for filtering
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
|
||||
|
||||
def test_id(self):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -344,6 +354,12 @@ class VirtualMachineTestCase(TestCase):
|
||||
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_has_primary_ip(self):
|
||||
params = {'has_primary_ip': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_local_context_data(self):
|
||||
params = {'local_context_data': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -13,6 +13,15 @@ EXIT=0
|
||||
RED='\033[0;31m'
|
||||
NOCOLOR='\033[0m'
|
||||
|
||||
if [ -d ./venv/ ]; then
|
||||
VENV="$PWD/venv"
|
||||
if [ -e $VENV/bin/python ]; then
|
||||
PATH=$VENV/bin:$PATH
|
||||
elif [ -e $VENV/Scripts/python.exe ]; then
|
||||
PATH=$VENV/Scripts:$PATH
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Validating PEP8 compliance..."
|
||||
pycodestyle --ignore=W504,E501 netbox/
|
||||
if [ $? != 0 ]; then
|
||||
|
||||
Reference in New Issue
Block a user