Compare commits

..

74 Commits

Author SHA1 Message Date
Jeremy Stretch
90f91eeea4 Merge pull request #8640 from netbox-community/develop
Release v3.1.8
2022-02-15 09:31:00 -05:00
jeremystretch
ae0ae5fd4e Remove outdated installation video 2022-02-15 09:20:19 -05:00
jeremystretch
5b7486cff8 Release v3.1.8 2022-02-15 09:01:58 -05:00
jeremystretch
14240318f1 Fixes #8609: Display validation error when attempting to assign VLANs to interface with no mode during bulk edit 2022-02-15 08:39:45 -05:00
jeremystretch
18eb9ffae6 Changelog for #8391 2022-02-14 10:34:30 -05:00
Jeremy Stretch
c0a62793c4 Merge pull request #8441 from seulsale/8391-install-date-null
Fixes #8391: Install date should appear empty when exported
2022-02-14 10:32:56 -05:00
jeremystretch
dd848d754f Changelog & cleanup for #8556 2022-02-14 10:06:56 -05:00
Jeremy Stretch
f058850598 Merge pull request #8581 from mathieu-mp/8556-add-full-name-to-change-log-tables
Closes #8556: Add 'Full Name' column to Change Log table
2022-02-14 09:49:36 -05:00
Jeremy Stretch
0c7220016b Merge pull request #8621 from JonathonReinhart/nbshell-tab-complete
Enable tab completion in nbshell
2022-02-14 09:27:35 -05:00
jeremystretch
8c19124717 Fixes #8622: Correct help text of status field on VM import form 2022-02-14 08:54:36 -05:00
Mathieu PAYROL
46f4359e1f Closes #8556: Add 'Full Name' column to Change Log table 2022-02-14 09:07:57 +01:00
Sergio Saucedo
f80452c7d9 Update import order 2022-02-14 00:47:48 -06:00
Sergio Saucedo
611f1b57dd Implement custom DateTimeColumn improving null values handling 2022-02-14 00:44:50 -06:00
Jonathon Reinhart
d1b1a45725 Enable tab completion in nbshell 2022-02-13 03:00:57 -05:00
jeremystretch
6e38f7e532 Changelog for #8577 2022-02-11 16:00:01 -05:00
Jeremy Stretch
2c1e681984 Merge pull request #8584 from 991jo/assigned_contacts_fix
Fixes #8577: Contact assignment amounts not shown during contact glob…
2022-02-11 15:49:02 -05:00
jeremystretch
f11ad99983 Fixes #8611: Fix bulk editing for certain custom link, webhook, and journal entry fields 2022-02-11 15:34:41 -05:00
jeremystretch
e1ef911d40 #8564: Fix deepmerge logic to allow nullifying dicts 2022-02-11 15:22:50 -05:00
jeremystretch
a4ca585ef2 Remove references to the old mailing list 2022-02-10 14:56:21 -05:00
jeremystretch
076461a1b6 Change notes for #7150, #8398 2022-02-10 14:22:40 -05:00
Jeremy Stretch
0c7407ebb6 Merge pull request #8592 from 991jo/fix_rack_svg_url
Fixes #7150: Devices on the elevations opposite side should be clickable
2022-02-10 14:21:01 -05:00
Jeremy Stretch
f13a3fa549 Merge pull request #8589 from ITJamie/patch-1
small documentation upgrade regarding group syncs
2022-02-10 14:11:12 -05:00
Markku Leiniö
c0a65eb593 Fixes #8398: Add ConfigParam.size to enlarge specific config fields (#8565)
* Fixes #8398: Add ConfigParam.size to enlarge specific config fields

* Revert "Fixes #8398: Add ConfigParam.size to enlarge specific config fields"

This reverts commit 05e8fff458.

* Use forms.Textarea for the banner config fields
2022-02-10 12:15:02 -05:00
jeremystretch
450a7730d3 Fixes #8578: Object change log tables should honor user's configured preferences 2022-02-10 12:07:09 -05:00
jeremystretch
41ee4b642f Fixes #8604: Fix tag filter on config context list filter form 2022-02-10 11:56:41 -05:00
thatmattlove
3ee3c52e14 Improve CI performance 2022-02-09 10:26:09 -07:00
Johannes Erwerle
e76a5bfd85 Fixes #7150: Devices on the elevations opposite side should be clickable 2022-02-09 15:07:36 +01:00
Jamie (Bear) Murphy
59c89a3b9d small documentation upgrade regarding group syncs
small documentation upgrade regarding group syncs
2022-02-09 13:05:51 +00:00
Sergio Saucedo
8fc605037a Implement custom DateColumn improving null values handling 2022-02-08 01:26:26 -06:00
Johannes Erwerle
311ddf82c5 Fixes #8577: Contact assignment amounts not shown during contact global search 2022-02-08 08:03:48 +01:00
thatmattlove
9d65486c64 Fixes #8564: reset the table config to an empty object when reset is clicked 2022-02-07 16:03:09 -07:00
thatmattlove
ccce7751a0 Fixes #8564: only use columns form field in user table config form submit 2022-02-07 14:36:28 -07:00
thatmattlove
7252f0b490 Add optional selector to getSelectedOptions for more specific field selection 2022-02-07 14:34:35 -07:00
thatmattlove
094d2e586a Fix code formatting 2022-02-07 14:14:43 -07:00
thatmattlove
6c1507c88c Implement replaceAll utility function
add #8331 release notes
2022-02-07 14:04:58 -07:00
mathieu-mp
60f48326e1 #8331 Maximize browser compatibility 2022-02-07 14:04:49 -07:00
jeremystretch
5b985a924b Changelog for #8548 & misc cleanup 2022-02-07 10:37:11 -05:00
Jeremy Stretch
ee74989f74 Merge pull request #8566 from tijshuisman/develop
Fixes #8548: Virtual Chassis position zero not shown in device page
2022-02-07 10:32:44 -05:00
Tijs Huisman
e2fc7e8cd7 Fixes #8548: Virtual Chassis position zero not shown in device page 2022-02-05 15:10:03 +01:00
jeremystretch
aff55881df Changelog for #8561 2022-02-04 16:22:30 -05:00
Jeremy Stretch
4d066a075d Merge pull request #8563 from jasonyates/8561-rear-console
Fixes #8561 - Unable to connect a cable from rear ports of a patch panel to a device console port
2022-02-04 16:17:07 -05:00
Jason Yates
201077b6f6 Fixes #8561 - Unable to connect a cable from rear ports of a patch panel to a device console port 2022-02-04 20:44:43 +00:00
jeremystretch
795134c084 PRVB 2022-02-03 11:34:36 -05:00
Jeremy Stretch
4f689223b4 Merge pull request #8540 from netbox-community/develop
Release v3.1.7
2022-02-03 11:31:48 -05:00
jeremystretch
70ce7293ac Release v3.1.7 2022-02-03 10:51:41 -05:00
jeremystretch
94a0a3b568 Closes #8502: Omit [all] from social-auth-core in base requirements 2022-02-03 10:39:39 -05:00
jeremystretch
69305f0509 Fixes #8315: Fix display of NAT link for primary IPv4 address under device view 2022-02-03 10:30:26 -05:00
jeremystretch
24f48b11e6 Closes #8530: Indicate CSV or YAML as format for "all data" export 2022-02-03 10:22:38 -05:00
jeremystretch
ff3b48fa59 Fixes #8527: Fix display of changelog retention period 2022-02-03 09:48:21 -05:00
jeremystretch
db3f478598 Closes #8517: Render boolean custom fields as icons in object tables 2022-02-02 16:24:51 -05:00
jeremystretch
e20ac803f3 Fixes #8498: Fix display of selected content type filters in object list views 2022-02-02 16:08:12 -05:00
Daniel Sheppard
ea283365e7 Fixes #8425 - Fix exception when viewing change list/records with removed plugins 2022-02-02 11:18:41 -06:00
jeremystretch
8211830bd8 Fixes #8514: Correct several links to config parameters 2022-02-02 09:27:29 -05:00
jeremystretch
2a8e0f9404 Update table accessors to use dunders in path 2022-02-02 09:18:50 -05:00
jeremystretch
c15cfc26f1 Fixes #8512: Correct file permissions to allow execution of housekeeping script 2022-02-01 16:58:09 -05:00
jeremystretch
4f4e6938eb Closes #7504: Include IP range data under IPAM role views 2022-02-01 16:47:29 -05:00
jeremystretch
8545a547b9 Closes #8494: Include locations count under tenant view 2022-02-01 16:31:34 -05:00
jeremystretch
3bb7184f28 Fixes #8499: Content types REST API endpoint should not require model permission 2022-02-01 15:14:13 -05:00
Jeremy Stretch
dd71942a5e Merge pull request #8489 from 991jo/fix-unittest-docs
Fixes #8477: The commands for running the tests in the development se…
2022-01-28 14:19:57 -05:00
jeremystretch
19fdd5e151 Fixes #8465: Accept empty string values for Interface rf_channel in REST API 2022-01-28 14:03:36 -05:00
jeremystretch
f537dc632e Fixes #8456: Fix redundant display of VRF RD in prefix view 2022-01-28 13:19:23 -05:00
jeremystretch
2221006970 Closes #8462: Linkify manufacturer column in device type table 2022-01-28 13:09:57 -05:00
Johannes Erwerle
5d29c5958b Fixes #8477: The commands for running the tests in the development section are not working 2022-01-28 17:54:37 +01:00
Jeremy Stretch
64dd46c7e4 Merge pull request #8482 from 991jo/feature-asn-ui-improvement
Fixes #8476: Bring the ASN Web UI up to the standard set by other obj…
2022-01-28 09:37:54 -05:00
Johannes Erwerle
8df382d976 Fixes #8476: Bring the ASN Web UI up to the standard set by other objects 2022-01-28 11:58:29 +01:00
Sergio Saucedo
31c58409e1 Set install_date default value as empty string 2022-01-24 02:36:27 -06:00
jeremystretch
69eb6b11d0 Closes #8368: Enable controlling the order of custom script form fields with field_order 2022-01-18 16:01:40 -05:00
jeremystretch
1f2d4fd2b3 Closes #8381: Add contacts to global search function 2022-01-18 15:40:19 -05:00
jeremystretch
21468fff25 Closes #8367: Add ASNs to global search function 2022-01-18 15:36:21 -05:00
jeremystretch
4711b4d529 Correct FeatureQuery invocations 2022-01-18 15:17:05 -05:00
Daniel Sheppard
29d4859e02 Fixes #8375 - Change ASN display column from ASDOT to ASPLAIN. Add ASDOT display column. 2022-01-18 11:23:52 -06:00
jeremystretch
4b81d86311 Closes #8376: Correct example condition defitinions; call out value vs label ealuation for choice fields 2022-01-18 11:31:39 -05:00
jeremystretch
38963e7960 Fixes #8377: Fix calculation of absolute cable lengths when specified in fractional units 2022-01-18 11:09:12 -05:00
jeremystretch
1584d51433 PRVB 2022-01-17 10:16:37 -05:00
73 changed files with 611 additions and 233 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.6
placeholder: v3.1.8
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.6
placeholder: v3.1.8
validations:
required: true
- type: dropdown

View File

@@ -38,6 +38,19 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Yarn Package Manager
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: yarn
cache-dependency-path: netbox/project-static/yarn.lock
- name: Install Frontend Dependencies
run: yarn --cwd netbox/project-static
- name: Install dependencies & set up configuration
run: |
@@ -45,7 +58,6 @@ jobs:
pip install -r requirements.txt
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
yarn --cwd netbox/project-static
- name: Build documentation
run: mkdocs build
@@ -63,7 +75,7 @@ jobs:
run: scripts/verify-bundles.sh
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*

View File

@@ -16,13 +16,6 @@ categories for discussions:
feature request
* **Q&A** - Request help with installing or using NetBox
### Mailing List
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
for general discussion, however we're encouraging people to use GitHub
discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).

View File

@@ -68,7 +68,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
### Installation

View File

@@ -100,7 +100,7 @@ PyYAML
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core[all]
social-auth-core
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django

0
contrib/netbox-housekeeping.sh Normal file → Executable file
View File

View File

@@ -1,5 +1,22 @@
{!models/extras/webhook.md!}
## Conditional Webhooks
A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
```json
{
"and": [
{
"attr": "status.value",
"value": "active"
}
]
}
```
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
## Webhook Processing
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.

View File

@@ -3,7 +3,7 @@
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.

View File

@@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
Default: `{}` (Empty dictionary)
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
---
@@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
Default: `False`
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
---

View File

@@ -77,6 +77,10 @@ This is the human-friendly names of your script. If omitted, the class name will
A human-friendly description of what your script does.
### `field_order`
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
### `commit_default`
The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default.

View File

@@ -50,7 +50,7 @@ The `fail()` method may optionally specify a field with which to associate the s
## Assigning Custom Validators
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/dynamic-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
1. Plain JSON mapping (no custom logic)
2. Dotted path to a custom validator class

View File

@@ -122,22 +122,22 @@ The demo data is provided in JSON format and loaded into an empty database using
## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
```no-highlight
$ python netbox/manage.py test
$ python manage.py test
```
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
```no-highlight
$ python netbox/manage.py test --keepdb
$ python manage.py test --keepdb
```
You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
```no-highlight
$ python netbox/manage.py test dcim.tests.test_views ipam.tests.test_views
$ python manage.py test dcim.tests.test_views ipam.tests.test_views
```
## Submitting Pull Requests

View File

@@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
There are several official forums for communication among the developers and community members:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance

View File

@@ -67,4 +67,4 @@ Authorization: Token $TOKEN
## Disabling the GraphQL API
If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/optional-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.
If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/dynamic-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.

View File

@@ -11,10 +11,6 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements
| Dependency | Minimum Version |

View File

@@ -81,16 +81,3 @@ If no body template is specified, the request body will be populated with a JSON
}
}
```
## Conditional Webhooks
A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
```json
{
"attr": "status",
"value": "active"
}
```
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).

View File

@@ -81,13 +81,16 @@ The following condition will evaluate as true:
```json
{
"attr": "status",
"attr": "status.value",
"value": ["planned", "staging"],
"op": "in",
"negate": true
}
```
!!! note "Evaluating static choice fields"
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match.
## Condition Sets
Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.
@@ -102,7 +105,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
{
"and": [
{
"attr": "status",
"attr": "status.value",
"value": "active"
},
{

View File

@@ -367,7 +367,7 @@ More information about IP ranges is available [in the documentation](../models/i
#### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963))
This release introduces the [`CUSTOM_VALIDATORS`](../configuration/optional-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description:
This release introduces the [`CUSTOM_VALIDATORS`](../configuration/dynamic-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description:
```python
from extras.validators import CustomValidator

View File

@@ -1,5 +1,58 @@
# NetBox v3.1
## v3.1.8 (2022-02-15)
### Enhancements
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
### Bug Fixes
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
---
## v3.1.7 (2022-02-03)
### Enhancements
* [#7504](https://github.com/netbox-community/netbox/issues/7504) - Include IP range data under IPAM role views
* [#8275](https://github.com/netbox-community/netbox/issues/8275) - Introduce alternative ASDOT-formatted column for ASNs
* [#8367](https://github.com/netbox-community/netbox/issues/8367) - Add ASNs to global search function
* [#8368](https://github.com/netbox-community/netbox/issues/8368) - Enable controlling the order of custom script form fields with `field_order`
* [#8381](https://github.com/netbox-community/netbox/issues/8381) - Add contacts to global search function
* [#8462](https://github.com/netbox-community/netbox/issues/8462) - Linkify manufacturer column in device type table
* [#8476](https://github.com/netbox-community/netbox/issues/8476) - Bring the ASN Web UI up to the standard set by other objects
* [#8494](https://github.com/netbox-community/netbox/issues/8494) - Include locations count under tenant view
* [#8517](https://github.com/netbox-community/netbox/issues/8517) - Render boolean custom fields as icons in object tables
* [#8530](https://github.com/netbox-community/netbox/issues/8530) - Indicate CSV or YAML as format for "all data" export
### Bug Fixes
* [#8315](https://github.com/netbox-community/netbox/issues/8315) - Fix display of NAT link for primary IPv4 address under device view
* [#8377](https://github.com/netbox-community/netbox/issues/8377) - Fix calculation of absolute cable lengths when specified in fractional units
* [#8425](https://github.com/netbox-community/netbox/issues/8425) - Fix exception when viewing change list/records with removed plugins
* [#8456](https://github.com/netbox-community/netbox/issues/8456) - Fix redundant display of VRF RD in prefix view
* [#8465](https://github.com/netbox-community/netbox/issues/8465) - Accept empty string values for Interface `rf_channel` in REST API
* [#8498](https://github.com/netbox-community/netbox/issues/8498) - Fix display of selected content type filters in object list views
* [#8499](https://github.com/netbox-community/netbox/issues/8499) - Content types REST API endpoint should not require model permission
* [#8512](https://github.com/netbox-community/netbox/issues/8512) - Correct file permissions to allow execution of housekeeping script
* [#8527](https://github.com/netbox-community/netbox/issues/8527) - Fix display of changelog retention period
---
## v3.1.6 (2022-01-17)
### Enhancements

View File

@@ -621,7 +621,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),

View File

@@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
def clean(self):
super().clean()
if not self.cleaned_data['mode']:
if self.cleaned_data['untagged_vlan']:
raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
elif self.cleaned_data['tagged_vlans']:
raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})

View File

@@ -0,0 +1,31 @@
from django.db import migrations
from utilities.utils import to_meters
def recalculate_abs_length(apps, schema_editor):
"""
Recalculate absolute lengths for all cables with a length and length unit defined. Fixes
incorrectly calculated values as reported under bug #8377.
"""
Cable = apps.get_model('dcim', 'Cable')
cables = Cable.objects.filter(length__isnull=False).exclude(length_unit='')
for cable in cables:
cable._abs_length = to_meters(cable.length, cable.length_unit)
Cable.objects.bulk_update(cables, ['_abs_length'], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0143_remove_primary_for_related_name'),
]
operations = [
migrations.RunPython(
code=recalculate_abs_length,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -126,10 +126,16 @@ class RackElevationSVG:
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc(self._get_device_description(device))
drawing.add(rect)
drawing.add(drawing.text(get_device_name(device), insert=text))
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, class_="slot blocked"))
link.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:

View File

@@ -45,7 +45,7 @@ class CableTable(BaseTable):
tenant = TenantColumn()
length = TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
order_by=('_abs_length', 'length_unit')
)
color = ColorColumn()
tags = TagColumn(

View File

@@ -261,7 +261,7 @@ class CableTerminationTable(BaseTable):
linkify=True
)
cable_color = ColorColumn(
accessor='cable.color',
accessor='cable__color',
orderable=False,
verbose_name='Cable Color'
)
@@ -276,7 +276,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable):
connection = TemplateColumn(
accessor='_path.last_node',
accessor='_path__last_node',
template_code=LINKTERMINATION,
verbose_name='Connection',
orderable=False

View File

@@ -67,6 +67,9 @@ class DeviceTypeTable(BaseTable):
linkify=True,
verbose_name='Device Type'
)
manufacturer = tables.Column(
linkify=True
)
is_full_depth = BooleanColumn(
verbose_name='Full Depth'
)

View File

@@ -82,7 +82,7 @@ class SiteTable(BaseTable):
linkify=True
)
asn_count = LinkedCountColumn(
accessor=tables.A('asns.count'),
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name='ASNs'

View File

@@ -9,7 +9,8 @@ LINKTERMINATION = """
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
{% load helpers %}
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
@@ -297,6 +298,8 @@ REARPORT_BUTTONS = """
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>

View File

@@ -9,6 +9,7 @@ from dcim.models import *
from ipam.models import ASN, RIR, VLAN
from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN
@@ -1239,10 +1240,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'name': 'Interface 4',
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
},
{
'device': device.pk,
@@ -1250,10 +1249,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'bridge': interfaces[0].pk,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
},
{
'device': device.pk,
@@ -1261,10 +1258,24 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'type': 'virtual',
'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[1].pk,
'tx_power': 10,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},
{
'device': device.pk,
'name': 'Interface 7',
'type': InterfaceTypeChoices.TYPE_80211A,
'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
},
{
'device': device.pk,
'name': 'Interface 8',
'type': InterfaceTypeChoices.TYPE_80211A,
'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': "",
},
]

View File

@@ -4,6 +4,7 @@ from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
@@ -382,6 +383,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
"""
permission_classes = (IsAuthenticated,)
queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet

View File

@@ -317,6 +317,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag_id = django_filters.ModelMultipleChoiceFilter(
field_name='tags',
queryset=Tag.objects.all(),
label='Tag',
)
tag = django_filters.ModelMultipleChoiceFilter(
field_name='tags__slug',
queryset=Tag.objects.all(),

View File

@@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
)
__all__ = (
'ConfigContextBulkEditForm',
@@ -44,7 +46,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
limit_choices_to=FeatureQuery('custom_links'),
required=False
)
new_window = forms.NullBooleanField(
@@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
required=False
)
button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices,
choices=add_blank_choice(CustomLinkButtonClassChoices),
required=False,
widget=StaticSelect()
)
@@ -71,7 +73,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
limit_choices_to=FeatureQuery('export_templates'),
required=False
)
description = forms.CharField(
@@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices,
required=False
choices=add_blank_choice(WebhookHttpMethodChoices),
required=False,
label='HTTP method'
)
payload_url = forms.CharField(
required=False
required=False,
label='Payload URL'
)
ssl_verification = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
widget=BulkEditNullBooleanSelect(),
label='SSL verification'
)
secret = forms.CharField(
required=False
)
ca_file_path = forms.CharField(
required=False
required=False,
label='CA file path'
)
class Meta:
@@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
widget=forms.MultipleHiddenInput
)
kind = forms.ChoiceField(
choices=JournalEntryKindChoices,
choices=add_blank_choice(JournalEntryKindChoices),
required=False
)
comments = forms.CharField(

View File

@@ -62,7 +62,7 @@ class CustomLinkFilterForm(FilterForm):
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
limit_choices_to=FeatureQuery('custom_links'),
required=False
)
weight = forms.IntegerField(
@@ -83,7 +83,7 @@ class ExportTemplateFilterForm(FilterForm):
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
limit_choices_to=FeatureQuery('export_templates'),
required=False
)
mime_type = forms.CharField(
@@ -109,7 +109,7 @@ class WebhookFilterForm(FilterForm):
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
limit_choices_to=FeatureQuery('webhooks'),
required=False
)
http_method = forms.MultipleChoiceField(
@@ -155,7 +155,7 @@ class TagFilterForm(FilterForm):
class ConfigContextFilterForm(FilterForm):
field_groups = [
['q', 'tag'],
['q', 'tag_id'],
['region_id', 'site_group_id', 'site_id'],
['device_type_id', 'platform_id', 'role_id'],
['cluster_group_id', 'cluster_id'],
@@ -211,9 +211,8 @@ class ConfigContextFilterForm(FilterForm):
required=False,
label=_('Tenant')
)
tag = DynamicModelMultipleChoiceField(
tag_id = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
label=_('Tags')
)

View File

@@ -70,10 +70,23 @@ class Command(BaseCommand):
return namespace
def handle(self, **options):
namespace = self.get_namespace()
# If Python code has been passed, execute it and exit.
if options['command']:
exec(options['command'], self.get_namespace())
exec(options['command'], namespace)
return
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
# Try to enable tab-complete
try:
import readline
import rlcompleter
except ModuleNotFoundError:
pass
else:
readline.set_completer(rlcompleter.Completer(namespace).complete)
readline.parse_and_bind('tab: complete')
# Run interactive shell
shell = code.interact(banner=BANNER_TEXT, local=namespace)
return shell

View File

@@ -296,12 +296,21 @@ class BaseScript:
@classmethod
def _get_vars(cls):
vars = OrderedDict()
vars = {}
for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
return vars
# Order variables according to field_order
field_order = getattr(cls.Meta, 'field_order', None)
if not field_order:
return vars
ordered_vars = {
field: vars.pop(field) for field in field_order if field in vars
}
ordered_vars.update(vars)
return ordered_vars
def run(self, data, commit):
raise NotImplementedError("The script must define a run() method.")

View File

@@ -29,8 +29,13 @@ CONFIGCONTEXT_ACTIONS = """
{% endif %}
"""
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ record.user.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if record.changed_object.get_absolute_url %}
{% if record.changed_object and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
@@ -204,6 +209,14 @@ class ObjectChangeTable(BaseTable):
linkify=True,
format=settings.SHORT_DATETIME_FORMAT
)
user_name = tables.Column(
verbose_name='Username'
)
full_name = tables.TemplateColumn(
template_code=OBJECTCHANGE_FULL_NAME,
verbose_name='Full Name',
orderable=False
)
action = ChoiceFieldColumn()
changed_object_type = ContentTypeColumn(
verbose_name='Type'
@@ -219,7 +232,7 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class ObjectJournalTable(BaseTable):

View File

@@ -608,7 +608,6 @@ class CreatedUpdatedFilterTest(APITestCase):
class ContentTypeTest(APITestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype'])
def test_list_objects(self):
contenttype_count = ContentType.objects.count()
@@ -616,7 +615,6 @@ class ContentTypeTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], contenttype_count)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype'])
def test_get_object(self):
contenttype = ContentType.objects.first()

View File

@@ -12,7 +12,7 @@ from extras.filtersets import *
from extras.models import *
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -429,6 +429,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Tenant.objects.bulk_create(tenants)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
for i in range(0, 3):
is_active = bool(i % 2)
c = ConfigContext.objects.create(
@@ -446,6 +448,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
c.clusters.set([clusters[i]])
c.tenant_groups.set([tenant_groups[i]])
c.tenants.set([tenants[i]])
c.tags.set([tags[i]])
def test_name(self):
params = {'name': ['Config Context 1', 'Config Context 2']}
@@ -516,13 +519,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_(self):
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tags(self):
tags = Tag.objects.all()[:2]
params = {'tag_id': [tags[0].pk, tags[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tag': [tags[0].slug, tags[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all()

View File

@@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
)
objectchanges_table = tables.ObjectChangeTable(
data=objectchanges,
orderable=False
orderable=False,
user=request.user
)
paginate_table(objectchanges_table, request)

View File

@@ -209,6 +209,10 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
qs_filter |= Q(asn=int(value))
except ValueError:
pass
return queryset.filter(qs_filter)

View File

@@ -46,7 +46,7 @@ class FHRPGroupTable(BaseTable):
class FHRPGroupAssignmentTable(BaseTable):
pk = ToggleColumn()
interface_parent = tables.Column(
accessor=tables.A('interface.parent_object'),
accessor=tables.A('interface__parent_object'),
linkify=True,
orderable=False,
verbose_name='Parent'

View File

@@ -107,21 +107,32 @@ class RIRTable(BaseTable):
class ASNTable(BaseTable):
pk = ToggleColumn()
asn = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True
)
asn_asdot = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True,
verbose_name='ASDOT'
)
site_count = LinkedCountColumn(
viewname='dcim:site_list',
url_params={'asn_id': 'pk'},
verbose_name='Sites'
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:asn_list'
)
actions = ButtonsColumn(ASN)
class Meta(BaseTable.Meta):
model = ASN
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created',
'last_updated', 'tags',
)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')
#
@@ -173,6 +184,11 @@ class RoleTable(BaseTable):
url_params={'role_id': 'pk'},
verbose_name='Prefixes'
)
iprange_count = LinkedCountColumn(
viewname='ipam:iprange_list',
url_params={'role_id': 'pk'},
verbose_name='IP Ranges'
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'role_id': 'pk'},
@@ -186,10 +202,10 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = Role
fields = (
'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions',
'created', 'last_updated',
'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'weight', 'tags',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'actions')
#
@@ -352,7 +368,7 @@ class IPAddressTable(BaseTable):
verbose_name='Interface'
)
assigned_object_parent = tables.Column(
accessor='assigned_object.parent_object',
accessor='assigned_object__parent_object',
linkify=True,
orderable=False,
verbose_name='Device/VM'

View File

@@ -340,6 +340,7 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
class RoleListView(generic.ObjectListView):
queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'),
iprange_count=count_related(IPRange, 'role'),
vlan_count=count_related(VLAN, 'role')
)
filterset = filtersets.RoleFilterSet

View File

@@ -20,19 +20,28 @@ PARAMS = (
name='BANNER_LOGIN',
label='Login banner',
default='',
description="Additional content to display on the login page"
description="Additional content to display on the login page",
field_kwargs={
'widget': forms.Textarea(),
},
),
ConfigParam(
name='BANNER_TOP',
label='Top banner',
default='',
description="Additional content to display at the top of every page"
description="Additional content to display at the top of every page",
field_kwargs={
'widget': forms.Textarea(),
},
),
ConfigParam(
name='BANNER_BOTTOM',
label='Bottom banner',
default='',
description="Additional content to display at the bottom of every page"
description="Additional content to display at the bottom of every page",
field_kwargs={
'widget': forms.Textarea(),
},
),
# IPAM

View File

@@ -12,12 +12,14 @@ from dcim.tables import (
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
VirtualChassisTable,
)
from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
from tenancy.filtersets import TenantFilterSet
from tenancy.models import Tenant
from tenancy.tables import TenantTable
from ipam.filtersets import (
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
)
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
from tenancy.models import Contact, Tenant, ContactAssignment
from tenancy.tables import ContactTable, TenantTable
from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine
@@ -170,6 +172,12 @@ SEARCH_TYPES = OrderedDict((
'table': VLANTable,
'url': 'ipam:vlan_list',
}),
('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ASNFilterSet,
'table': ASNTable,
'url': 'ipam:asn_list',
}),
# Tenancy
('tenant', {
'queryset': Tenant.objects.prefetch_related('group'),
@@ -177,4 +185,10 @@ SEARCH_TYPES = OrderedDict((
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet,
'table': ContactTable,
'url': 'tenancy:contact_list',
}),
))

View File

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1.6'
VERSION = '3.1.8'
# Hostname
HOSTNAME = platform.node()

View File

@@ -133,7 +133,7 @@ class HomeView(View):
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
'user', 'changed_object_type'
)[:10]
changelog_table = ObjectChangeTable(changelog)
changelog_table = ObjectChangeTable(changelog, user=request.user)
# Check whether a new release is available. (Only for staff/superusers.)
new_release = None

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams, isOption } from './types';
import {
hasMore,
isTruthy,
hasError,
getElement,
isTruthy,
getApiData,
getElement,
isApiError,
replaceAll,
createElement,
uniqueByProperty,
findFirstAdjacent,
@@ -461,7 +462,7 @@ export class APISelect {
// Set any primitive k/v pairs as data attributes on each option.
for (const [k, v] of Object.entries(result)) {
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
const key = k.replaceAll('_', '-');
const key = replaceAll(k, '_', '-');
data[key] = String(v);
}
// Set option to disabled if the result contains a matching key and is truthy.
@@ -659,7 +660,7 @@ export class APISelect {
for (const [key, value] of this.pathValues.entries()) {
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (isTruthy(value)) {
url = url.replaceAll(result[1], value.toString());
url = replaceAll(url, result[1], value.toString());
}
}
}
@@ -741,7 +742,7 @@ export class APISelect {
* @param id DOM ID of the other element.
*/
private updatePathValues(id: string): void {
const key = id.replaceAll(/^id_/gi, '');
const key = replaceAll(id, /^id_/i, '');
const element = getElement<HTMLSelectElement>(`id_${key}`);
if (element !== null) {
// If this element's URL contains Django template tags ({{), replace the template tag
@@ -919,16 +920,18 @@ export class APISelect {
style.setAttribute('data-netbox', id);
// Scope the CSS to apply both the list item and the selected item.
style.innerHTML = `
style.innerHTML = replaceAll(
`
div.ss-values div.ss-value[data-id="${id}"],
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
{
background-color: ${bg} !important;
color: ${fg} !important;
}
`
.replaceAll('\n', '')
.trim();
`,
'\n',
'',
).trim();
// Add the style element to the DOM.
document.head.appendChild(style);

View File

@@ -11,15 +11,6 @@ function saveTableConfig(): void {
}
}
/**
* Delete all selected columns, which reverts the user's preferences to the default column set.
*/
function resetTableConfig(): void {
for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
element.value = '';
}
}
/**
* Add columns to the table config select element.
*/
@@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
/**
* Submit form configuration to the NetBox API.
*/
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
async function submitFormConfig(
url: string,
formConfig: Dict<Dict>,
): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>(url, formConfig);
}
@@ -70,25 +64,46 @@ function handleSubmit(event: Event): void {
const url = element.getAttribute('data-url');
if (url == null) {
const toast = createToast(
'danger',
'Error Updating Table Configuration',
'No API path defined for configuration form.'
'danger',
'Error Updating Table Configuration',
'No API path defined for configuration form.',
);
toast.show();
return;
}
// Determine if the form action is to reset the table config.
const reset = document.activeElement?.getAttribute('value') === 'Reset';
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
// ['tables', 'DevicePowerOutletTable']
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
if (reset) {
// If we're resetting the table config, create an empty object for this table. E.g.
// tables.PlatformTable becomes {tables: PlatformTable: {}}
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
// Submit the reset for configuration to the API.
submitFormConfig(url, data).then(res => {
if (hasError(res)) {
const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
toast.show();
} else {
location.reload();
}
});
return;
}
// Get all the selected options from any select element in the form.
const options = getSelectedOptions(element);
const options = getSelectedOptions(element, 'select[name=columns]');
// Create an object mapping the select element's name to all selected options for that element.
const formData: Dict<Dict<string>> = Object.assign(
{},
...options.map(opt => ({ [opt.name]: opt.options })),
);
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
// ['tables', 'DevicePowerOutletTable']
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
// Create an object mapping the configuration path to the select element names, which contain the
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
@@ -112,9 +127,6 @@ export function initTableConfig(): void {
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
element.addEventListener('click', saveTableConfig);
}
for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
element.addEventListener('click', resetTableConfig);
}
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
element.addEventListener('click', addColumns);
}

View File

@@ -1,4 +1,4 @@
import { getElements, findFirstAdjacent } from '../util';
import { getElements, replaceAll, findFirstAdjacent } from '../util';
type InterfaceState = 'enabled' | 'disabled';
type ShowHide = 'show' | 'hide';
@@ -105,9 +105,9 @@ class ButtonState {
*/
private toggleButton(): void {
if (this.buttonState === 'show') {
this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide');
this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
} else if (this.buttonState === 'hide') {
this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show');
this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
}
}

View File

@@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
* Iterate through a select element's options and return an array of options that are selected.
*
* @param base Select element.
* @param selector Optionally specify a selector. 'select' by default.
* @returns Array of selected options.
*/
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
export function getSelectedOptions<E extends HTMLElement>(
base: E,
selector: string = 'select',
): SelectedOption[] {
let selected = [] as SelectedOption[];
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
if (element !== null) {
const select = { name: element.name, options: [] } as SelectedOption;
for (const option of element.options) {
@@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
if (element !== null) {
if (isTruthy(element.innerText) && element.innerText !== '—') {
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
yield replaceAll(element.innerText, '[\n\r]', '').trim();
}
}
}
@@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
}
return Array.from(baseMap.values());
}
/**
* Replace all occurrences of a pattern with a replacement string.
*
* This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
* introduced in ES2021.
*
* @param input string to be processed.
* @param pattern regex pattern string or RegExp object to search for.
* @param replacement replacement substring with which `pattern` matches will be replaced.
* @returns processed version of `input`.
*/
export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
// Ensure input is a string.
if (typeof input !== 'string') {
throw new TypeError("replaceAll 'input' argument must be a string");
}
// Ensure pattern is a string or RegExp.
if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
}
// Ensure replacement is able to be stringified.
switch (typeof replacement) {
case 'boolean':
replacement = String(replacement);
break;
case 'number':
replacement = String(replacement);
break;
case 'string':
break;
default:
throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
}
if (pattern instanceof RegExp) {
// Add global flag to existing RegExp object and deduplicate
const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
pattern = new RegExp(pattern.source, flags);
} else {
// Create a RegExp object with the global flag set.
pattern = new RegExp(pattern, 'g');
}
return input.replace(pattern, replacement);
}

View File

@@ -33,7 +33,8 @@
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
</td>
</tr>
<th scope="row">Location</th>
<tr>
<th scope="row">Location</th>
<td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
@@ -129,7 +130,7 @@
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
</td>
<td>
{% badge vc_member.vc_position %}
{% badge vc_member.vc_position show_empty=True %}
</td>
<td>
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
@@ -188,7 +189,7 @@
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }})">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside %}
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
{% endif %}

View File

@@ -11,7 +11,7 @@
</div>
</div>
<div class="text-muted">
Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
Change log retention: {% if config.CHANGELOG_RETENTION %}{{ config.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
</div>
</div>
</div>

View File

@@ -5,12 +5,14 @@
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
{% if object.related_object.get_absolute_url %}
{% if object.related_object and object.related_object.get_absolute_url %}
<li class="breadcrumb-item"><a href="{{ object.related_object.get_absolute_url }}changelog/">{{ object.related_object }}</a></li>
{% elif object.changed_object.get_absolute_url %}
{% elif object.changed_object and object.changed_object.get_absolute_url %}
<li class="breadcrumb-item"><a href="{{ object.changed_object.get_absolute_url }}changelog/">{{ object.changed_object }}</a></li>
{% elif object.changed_object %}
{% elif object.changed_object and object.changed_object.get_display %}
<li class="breadcrumb-item">{{ object.changed_object }}</li>
{% else %}
<li class="breadcrumb-item">{{ object.object_repr }}</li>
{% endif %}
{% endblock %}
@@ -36,7 +38,11 @@
<tr>
<th scope="row">User</th>
<td>
{{ object.user|default:object.user_name }}
{% if object.user.get_full_name %}
{{ object.user.get_full_name }} ({{ object.user_name }})
{% else %}
{{ object.user_name }}
{% endif %}
</td>
</tr>
<tr>
@@ -54,7 +60,7 @@
<tr>
<th scope="row">Object</th>
<td>
{% if object.changed_object.get_absolute_url %}
{% if object.changed_object and object.changed_object.get_absolute_url %}
<a href="{{ object.changed_object.get_absolute_url }}">{{ object.changed_object }}</a>
{% else %}
{{ object.object_repr }}

View File

@@ -2,8 +2,9 @@
{% block title %}Change Log{% endblock %}
{% block sidebar %}
<div class="text-muted">
Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
</div>
{% block content-wrapper %}
{{ block.super }}
<div class="text-muted px-3">
Change log retention: {% if config.CHANGELOG_RETENTION %}{{ config.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
</div>
{% endblock %}

View File

@@ -18,7 +18,7 @@
<th scope="row">VRF</th>
<td>
{% if object.vrf %}
<a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a> ({{ object.vrf.rd }})
<a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
{% else %}
<span>Global</span>
{% endif %}

View File

@@ -38,6 +38,30 @@
<a href="{% url 'ipam:prefix_list' %}?role_id={{ object.pk }}">{{ prefixes_table.rows|length }}</a>
</td>
</tr>
<tr>
<th scope="row">IP Ranges</th>
<td>
{% with ipranges_count=object.ip_ranges.count %}
{% if ipranges_count %}
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
{% else %}
&mdash;
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">VLANs</th>
<td>
{% with vlans_count=object.vlans.count %}
{% if vlans_count %}
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a>
{% else %}
&mdash;
{% endif %}
{% endwith %}
</td>
</tr>
</table>
</div>
</div>

View File

@@ -59,6 +59,10 @@
<h2><a href="{% url 'dcim:rackreservation_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.rackreservation_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rackreservation_count }}</a></h2>
<p>Rack reservations</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:location_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.location_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.location_count }}</a></h2>
<p>Locations</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
<p>Devices</p>
@@ -71,6 +75,10 @@
<h2><a href="{% url 'ipam:aggregate_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.aggregate_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.aggregate_count }}</a></h2>
<p>Aggregates</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:asn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
<p>ASNs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
<p>Prefixes</p>

View File

@@ -1,10 +1,9 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation, Cable
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN
from netbox.views import generic
from utilities.tables import paginate_table
from utilities.utils import count_related
@@ -103,6 +102,7 @@ class TenantView(generic.ObjectView):
'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
@@ -113,6 +113,7 @@ class TenantView(generic.ObjectView):
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
}
return {

View File

@@ -127,12 +127,13 @@ def get_selected_values(form, field_name):
if not hasattr(field, 'choices'):
return [str(filter_data)]
# Get choice labels
# Model choice field
if type(field.choices) is forms.models.ModelChoiceIterator:
# Field uses dynamic choices: show all that have been populated on the widget
values = [
subwidget.choice_label for subwidget in form[field_name].subwidgets
]
# If this is a single-choice field, wrap its value in a list
if not hasattr(filter_data, '__iter__'):
values = [filter_data]
else:
values = filter_data
else:
# Static selection field

View File

@@ -4,10 +4,13 @@ from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist
from django.db.models import DateField, DateTimeField
from django.db.models.fields.related import RelatedField
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
from django_tables2 import RequestConfig
from django_tables2.columns import library
from django_tables2.data import TableQuerysetData
from django_tables2.utils import Accessor
@@ -205,6 +208,42 @@ class TemplateColumn(tables.TemplateColumn):
return ret
@library.register
class DateColumn(tables.DateColumn):
"""
Overrides the default implementation of DateColumn to better handle null values, returning a default value for
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField.
"""
def value(self, value):
return value
@classmethod
def from_field(cls, field, **kwargs):
if isinstance(field, DateField):
return cls(**kwargs)
@library.register
class DateTimeColumn(tables.DateTimeColumn):
"""
Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField.
"""
def value(self, value):
if value:
return date_format(value, format="SHORT_DATETIME_FORMAT")
return None
@classmethod
def from_field(cls, field, **kwargs):
if isinstance(field, DateTimeField):
return cls(**kwargs)
class ButtonsColumn(tables.TemplateColumn):
"""
Render edit, delete, and changelog buttons for an object.
@@ -414,13 +453,23 @@ class CustomFieldColumn(tables.Column):
def render(self, value):
if isinstance(value, list):
return ', '.join(v for v in value)
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
# Linkify custom URLs
return mark_safe(f'<a href="{value}">{value}</a>')
if value is not None:
return value
return self.default
def value(self, value):
if isinstance(value, list):
return ','.join(v for v in value)
if value is not None:
return value
return self.default
class CustomLinkColumn(tables.Column):
"""

View File

@@ -1,31 +1,31 @@
<div class="dropdown">
<button type="button" class="btn btn-sm btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-download"></i>&nbsp;Export
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export=table">Current View</a></li>
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">All Data</a></li>
{% if export_templates %}
<button type="button" class="btn btn-sm btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-download"></i>&nbsp;Export
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">Current View</a></li>
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">All Data ({{ data_format }})</a></li>
{% if export_templates %}
<li>
<hr class="dropdown-divider">
</li>
{% for et in export_templates %}
<li>
<hr class="dropdown-divider">
</li>
{% for et in export_templates %}
<li>
<a class="dropdown-item"
href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %}
title="{{ et.description }}"{% endif %}>
<a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export={{ et.name }}"
{% if et.description %} title="{{ et.description }}"{% endif %}
>
{{ et.name }}
</a>
</li>
{% endfor %}
{% endif %}
{% if add_exporttemplate_link %}
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="{{ add_exporttemplate_link }}">Add export template...</a>
</li>
{% endif %}
</ul>
{% endfor %}
{% endif %}
{% if perms.extras.add_exporttemplate %}
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_type={{ content_type.pk }}">Add export template...</a>
</li>
{% endif %}
</ul>
</div>

View File

@@ -81,19 +81,19 @@ def import_button(url):
@register.inclusion_tag('buttons/export.html', takes_context=True)
def export_button(context, content_type=None):
add_exporttemplate_link = None
def export_button(context, content_type):
user = context['request'].user
if content_type is not None:
user = context['request'].user
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type)
if user.is_staff and user.has_perm('extras.add_exporttemplate'):
add_exporttemplate_link = f"{reverse('extras:exporttemplate_add')}?content_type={content_type.pk}"
else:
export_templates = []
# Determine if the "all data" export returns CSV or YAML
data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV'
# Retrieve all export templates for this model
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type)
return {
'url_params': context['request'].GET,
'perms': context['perms'],
'content_type': content_type,
'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
'export_templates': export_templates,
'add_exporttemplate_link': add_exporttemplate_link,
'data_format': data_format,
}

View File

@@ -1,9 +1,8 @@
import datetime
import json
import urllib
from collections import OrderedDict
from decimal import Decimal
from itertools import count, groupby
from typing import Any, Dict, List, Tuple
from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery
@@ -184,7 +183,7 @@ def deepmerge(original, new):
"""
merged = OrderedDict(original)
for key, val in new.items():
if key in original and isinstance(original[key], dict) and isinstance(val, dict):
if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
merged[key] = deepmerge(original[key], val)
else:
merged[key] = val
@@ -195,15 +194,15 @@ def to_meters(length, unit):
"""
Convert the given length to meters.
"""
length = int(length)
if length < 0:
raise ValueError("Length must be a positive integer")
try:
if length < 0:
raise ValueError("Length must be a positive number")
except TypeError:
raise TypeError(f"Invalid value '{length}' for length (must be a number)")
valid_units = CableLengthUnitChoices.values()
if unit not in valid_units:
raise ValueError(
"Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
)
raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
if unit == CableLengthUnitChoices.UNIT_KILOMETER:
return length * 1000
@@ -212,11 +211,11 @@ def to_meters(length, unit):
if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
return length / 100
if unit == CableLengthUnitChoices.UNIT_MILE:
return length * 1609.344
return length * Decimal(1609.344)
if unit == CableLengthUnitChoices.UNIT_FOOT:
return length * 0.3048
return length * Decimal(0.3048)
if unit == CableLengthUnitChoices.UNIT_INCH:
return length * 0.3048 * 12
return length * Decimal(0.3048) * 12
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")

View File

@@ -64,7 +64,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
class VirtualMachineCSVForm(CustomFieldModelCSVForm):
status = CSVChoiceField(
choices=VirtualMachineStatusChoices,
help_text='Operational status of device'
help_text='Operational status'
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),

View File

@@ -1,4 +1,4 @@
Django==3.2.11
Django==3.2.12
django-cors-headers==3.11.0
django-debug-toolbar==3.2.4
django-filter==21.1
@@ -9,7 +9,7 @@ django-prometheus==2.2.0
django-redis==5.2.0
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==2.0.0
django-taggit==2.1.0
django-timezone-field==4.2.3
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
@@ -18,15 +18,15 @@ gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==8.1.7
mkdocs-material==8.1.11
netaddr==0.8.0
Pillow==8.4.0
Pillow==9.0.1
psycopg2-binary==2.9.3
PyYAML==6.0
social-auth-app-django==5.0.0
social-auth-core==4.1.0
social-auth-core==4.2.0
svgwrite==1.4.1
tablib==3.1.0
tablib==3.2.0
# Workaround for #7401
jsonschema==3.2.0