Compare commits

..

53 Commits

Author SHA1 Message Date
Jeremy Stretch
dfcfbe240d Merge pull request #13614 from netbox-community/develop
Release v3.6.0
2023-08-30 14:51:04 -04:00
Jeremy Stretch
b040fdcf2c Release v3.6.0 2023-08-30 14:27:07 -04:00
Jeremy Stretch
8525f994c0 Fix invalid links 2023-08-30 14:21:04 -04:00
Jeremy Stretch
eb9a804914 #12591: Add a dedicated view for the active config revision 2023-08-30 11:13:56 -04:00
Jeremy Stretch
210d7bb573 Display last_updated time only if defined 2023-08-30 11:13:02 -04:00
Jeremy Stretch
dc85476b9e Changelog for #11478, #13513, #13599, #13605 2023-08-30 09:36:44 -04:00
Daniel Sheppard
1854a6b76b Fix #11478 - Add vc_interfaces flag to control selection of VC interfaces (#13296)
* Add `vc_interfaces` flag to control interface queryset

* Fix test failure

* Add new filters instead of using undocumented query params

* Cleanup filterset, add test

* Rename filter and re-introduce virtual_chassis filtering method (required)

* Fix test

* Adjust tests to more accurately provide coverage

* Add breaking change note

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-30 09:33:02 -04:00
Jeremy Stretch
aebf3288d1 Fixes #13605: Specify batch size for cached counter migrations (#13610)
* Specify batch size for cached counter migrations

* Remove list() casting of querysets
2023-08-30 09:18:24 -04:00
Arthur Hanson
065a40dfb3 13599 fix cached counter for edit object (#13600)
* 13599 fix cache counter

* 13599 update test

* Merge conditionals

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-29 15:31:13 -04:00
Jeremy Stretch
83536fbb23 #12814: Add context data section to config rendering doc 2023-08-29 14:43:07 -04:00
Jeremy Stretch
420090dc6c #12590: Exclude proxy model for Token from permission object types 2023-08-29 14:41:14 -04:00
Jeremy Stretch
4ab0eb570c #11305: Add latitude & longitude to DeviceWithConfigContextSerializer 2023-08-29 14:31:42 -04:00
Jeremy Stretch
2a4e3dd09f Merge branch 'develop' into feature 2023-08-29 10:45:55 -04:00
Jeremy Stretch
0dbfbf6941 Merge pull request #13591 from netbox-community/develop
Correct version number
2023-08-28 17:07:15 -04:00
Jeremy Stretch
d515530277 Merge branch 'master' into develop 2023-08-28 17:05:59 -04:00
Jeremy Stretch
4343e0566b Correct version number 2023-08-28 17:04:37 -04:00
Jeremy Stretch
8555269f7e Merge pull request #13589 from netbox-community/develop
Release v3.5.9
2023-08-28 16:58:09 -04:00
Jeremy Stretch
f42a2ac10c Merge branch 'master' into develop 2023-08-28 16:19:44 -04:00
Jeremy Stretch
4ea3a29c0e Release v3.5.9 2023-08-28 16:13:13 -04:00
Arthur Hanson
29877c9abe 12489 Use HTMX for Location and Non-Racked Devices in Site detail view (#12491)
* 12489 use htmx for site view locations and non-racked-devices

* 12489 remove now unused queries in context

* adds device type and role to device component filter #12015

* Revert "Fixes #12463: Fix the association of completed jobs with reports & scripts in the REST API"

This reverts commit a29a07ed26.

* 12489 update nonracked_devices on rack and location templates

* 12489 fix whitespace issue

* Undo errant commits

* 12489 update site id in templates

* 12489 remove nonracked_devices include

* 12489 add has_position filter

* Use empty lookup for position field

* Remove non-racked devices list from rack view (was moved to a tab)

* Clean up location and device tables

* Restore plugins block on rack template

---------

Co-authored-by: Abhimanyu Saharan <desk.abhimanyu@gmail.com>
Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-08-28 16:03:35 -04:00
Jeremy Stretch
480f83c42d Closes #13585: Introduce 'empty' lookup for numeric value filters 2023-08-28 15:25:37 -04:00
Jeremy Stretch
faf89350ac Fixes #13569: Fix selection widgets for related interfaces when bulk editing interfaces under device view 2023-08-28 13:04:42 -04:00
Jeremy Stretch
d9c3ce935f Changelog for #12825, #13313, #13415, #13507, #13542, #13543, #13544, #13556 2023-08-28 09:10:44 -04:00
Abhimanyu Saharan
8d8f57e8b8 Adds parent filter on iprange (#13568)
* adds parent filter on iprange #13313

* lint fix

* adds filterset test

* Filter should match both start & end of IP range

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-28 09:05:43 -04:00
Abhimanyu Saharan
0a3be0b7ea adds related models count on custom field #12825 2023-08-28 08:34:33 -04:00
Abhimanyu Saharan
00ebdfe0df adds related models count on custom field #12825 2023-08-28 08:34:33 -04:00
Jeremy Stretch
d79fa131bb Closes #13415: Pass request context when rendering custom links in a table column 2023-08-25 13:14:47 -04:00
Abhimanyu Saharan
be2b24a155 fixes the swagger schema for token provisioning #13557 2023-08-25 09:45:03 -04:00
Abhimanyu Saharan
03b341dbfd adds missing status choicefield for vdc #13556 2023-08-25 09:40:04 -04:00
Arthur
ca5e69897d 13396 upgrade graphiql 2023-08-24 14:17:09 -04:00
Abhimanyu Saharan
3090dd4934 Fixed permission for config context UI view (#13547)
* fixed permission for config context UI view #13543

* removed extras.view_configcontext permission #13543
2023-08-24 14:13:31 -04:00
Abhimanyu Saharan
1f1d1ee502 adds additional safe HTTP headers to request #13542 2023-08-24 14:12:08 -04:00
Abhimanyu Saharan
1c2cf11f47 fixes global search when the content type is not found #13507 2023-08-24 14:09:48 -04:00
Jeremy Stretch
08961e751d Revert changes from #13373 pending further discussion around implementation
This reverts commit 66e4e31209.
2023-08-24 14:02:15 -04:00
Abhimanyu Saharan
88bf82be05 clear all cache when lazy is not used #13544 2023-08-24 10:12:48 -04:00
Jeremy Stretch
506884bc4d Changelog for #11272, #13516, #13530, #13536 2023-08-23 14:44:14 -04:00
Jeremy Stretch
646fa341ab Closes #13470: Remove misleading statement about access to report results 2023-08-23 14:41:38 -04:00
Jeremy Stretch
d73f7b1943 Fixes #13530: Ensure script log messages are cast as strings for proper serialization 2023-08-23 14:41:21 -04:00
Abhimanyu Saharan
a75e8416a4 adds vlan child table to vlan group #13536 2023-08-23 13:39:10 -04:00
Arthur
f743f2cfb8 11272 make position field work correctly when internationalizion enabled 2023-08-23 13:30:01 -04:00
Jeremy Stretch
7d7e8127f5 Fixes #13513: Prevent exception when rendering bookmarks widget for anonymous user 2023-08-23 10:53:56 -04:00
Jeremy Stretch
3c0a3ca703 Fixes #13516: Plugin utility functions should be importable from extras.plugins 2023-08-22 10:27:21 -04:00
Jeremy Stretch
45062697c5 Changelog for #11508, #13358, #13477, #13478, #13500, #13503 2023-08-21 15:10:12 -04:00
Arthur Hanson
66e4e31209 11508 Add group assignments for Azure SSO (#13373)
* 11508 temp azure changes

* 11508 map AzureAD groups to NetBox groups

* 11508 add is_active, reset superuser and staff based on Azure

* 11508 remove is_active, add documentation use azuread

* 11508 remove addition to settings

* 11508 review changes, add additional logging and error checking

* 11508 review changes, remove extra flag

* 11508 review changes, change SOCIAL_AUTH_ to REMOTE_AUTH_BACKEND

* 11508 clear user groups

* 11508 clear user groups

* 11508 review feedback change config key

* 11508 review changes

* 11508 review changes - add error checking

* 11508 review changes - flexible config params
2023-08-21 14:42:16 -04:00
kkthxbye-code
c86cfe3cbf Correct filter name in redirect after bulk edit
* Added modified_by_request filter to ChangeLoggedFilterSet
2023-08-21 14:35:08 -04:00
Arthur
28e112743f 13503 fix rack space utilization graph for internationalization 2023-08-21 14:21:50 -04:00
Arthur
229007082b 13510 update docs run permission image 2023-08-21 14:03:31 -04:00
Abhimanyu Saharan
4004966b16 fix content type filter on export template #13478 2023-08-17 15:29:21 -04:00
Arthur
fe95cb434a 13500 fix l2vpntermination bulk update 2023-08-17 15:25:23 -04:00
Alexander Haase
16e2283d19 Fix git DataSource clone authentication
Anonymous git clones (in GitLab) require the username and password not
to be set in order to successfully clone. This patch will define clone
args only, if the username passed is not empty.
2023-08-15 13:29:03 -04:00
Jeremy Stretch
c46536f469 Merge pull request #13474 from jose-d/develop-1
upgrading.md: there shouldbe OLDVER instead of NEWVER
2023-08-15 11:26:43 -04:00
jose_d
9450ce4c3a upgrading.md: there shouldbe OLDVER instead of NEWVER 2023-08-15 16:19:31 +02:00
Jeremy Stretch
8f5005efd5 Merge pull request #13472 from netbox-community/develop
Release v3.5.8
2023-08-15 09:56:23 -04:00
68 changed files with 798 additions and 1275 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.5.8
placeholder: v3.6.0
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.5.8
placeholder: v3.6.0
validations:
required: true
- type: dropdown

View File

@@ -332,6 +332,7 @@
"100gbase-x-cfp",
"100gbase-x-cfp2",
"200gbase-x-cfp2",
"400gbase-x-cfp2",
"100gbase-x-cfp4",
"100gbase-x-cxp",
"100gbase-x-cpak",

View File

@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
!!! note
These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend).
These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
### Archive the Media Directory

View File

@@ -4,7 +4,7 @@
Default: Empty
A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here.
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
!!! warning
Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled.

View File

@@ -111,7 +111,7 @@ The following methods are available to log results within a report:
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`.
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.

View File

@@ -37,6 +37,14 @@ Configuration templates are written in the [Jinja2 templating language](https://
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
### Context Data
The objet for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
```
There are {{ dcim.Site.objects.count() }} sites.
```
## Rendering Templates
### Device Configurations

View File

@@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
```no-highlight
# Set $OLDVER to the NetBox version currently installed
NEWVER=3.4.9
OLDVER=3.4.9
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -61,13 +61,14 @@ These lookup expressions can be applied by adding a suffix to the desired field'
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `lt` | Less than |
| `lte` | Less than or equal to |
| `gt` | Greater than |
| `gte` | Greater than or equal to |
| Filter | Description |
|---------|--------------------------|
| `n` | Not equal to |
| `lt` | Less than |
| `lte` | Less than or equal to |
| `gt` | Greater than |
| `gte` | Greater than or equal to |
| `empty` | Is empty/null (boolean) |
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
@@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
String based (char) fields (Name, Address, etc) support these lookup expressions:
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty (boolean) |
| Filter | Description |
|---------|----------------------------------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.6](./version-3.6.md) (August 2023)
* Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
* Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
* User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
* Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
* Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
* Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
#### [Version 3.5](./version-3.5.md) (April 2023)
* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))

View File

@@ -1,6 +1,32 @@
# NetBox v3.5
## v3.5.9 (FUTURE)
## v3.5.9 (2023-08-28)
### Enhancements
* [#12489](https://github.com/netbox-community/netbox/issues/12489) - Dynamically render location and device lists under site and location views
* [#12825](https://github.com/netbox-community/netbox/issues/12825) - Display assigned values count per obejct type under custom field view
* [#13313](https://github.com/netbox-community/netbox/issues/13313) - Enable filtering IP ranges by containing prefix
* [#13415](https://github.com/netbox-community/netbox/issues/13415) - Include request object in custom link renderer on tables
* [#13536](https://github.com/netbox-community/netbox/issues/13536) - Move child VLANs list to a separate tab under VLAN group view
* [#13542](https://github.com/netbox-community/netbox/issues/13542) - Pass additional HTTP headers through to custom script context
* [#13585](https://github.com/netbox-community/netbox/issues/13585) - Introduce `empty` lookup for numeric value filters
### Bug Fixes
* [#11272](https://github.com/netbox-community/netbox/issues/11272) - Fix localization support for device position field
* [#13358](https://github.com/netbox-community/netbox/issues/13358) - Git backend should send HTTP auth headers only if credentials have been defined
* [#13477](https://github.com/netbox-community/netbox/issues/13477) - Fix filtering of modified objects after bulk import/update
* [#13478](https://github.com/netbox-community/netbox/issues/13478) - Fix filtering of export templates by content type under web UI
* [#13500](https://github.com/netbox-community/netbox/issues/13500) - Fix form validation for bulk update of L2VPN terminations via bulk import form
* [#13503](https://github.com/netbox-community/netbox/issues/13503) - Fix utilization graph proportions when localization is enabled
* [#13507](https://github.com/netbox-community/netbox/issues/13507) - Avoid raising exception for invalid content type during global search
* [#13516](https://github.com/netbox-community/netbox/issues/13516) - Plugin utility functions should be importable from `extras.plugins`
* [#13530](https://github.com/netbox-community/netbox/issues/13530) - Ensure script log messages can be serialized as JSON data
* [#13543](https://github.com/netbox-community/netbox/issues/13543) - Config context tab under device/VM view should not require `extras.view_configcontext` permission
* [#13544](https://github.com/netbox-community/netbox/issues/13544) - Ensure `reindex` command clears all cached values when not in lazy mode
* [#13556](https://github.com/netbox-community/netbox/issues/13556) - Correct REST API representation of VDC status choice
* [#13569](https://github.com/netbox-community/netbox/issues/13569) - Fix selection widgets for related interfaces when bulk editing interfaces under device view
---

View File

@@ -1,21 +1,6 @@
# NetBox v3.6
## v3.6-beta2 (2023-08-16)
### Bug Fixes
* [#13351](https://github.com/netbox-community/netbox/issues/13351) - Fix missing text due to incorrectly applied translation tags
* [#13361](https://github.com/netbox-community/netbox/issues/13361) - Extra choices field on custom field choice set form should not be required
* [#13363](https://github.com/netbox-community/netbox/issues/13363) - Fix API endpoint for custom field choice selector in forms
* [#13376](https://github.com/netbox-community/netbox/issues/13376) - Restrict add/remove tag fields by model on bulk edit forms
* [#13410](https://github.com/netbox-community/netbox/issues/13410) - Fix rendering of custom choice fields with large number of choices
* [#13433](https://github.com/netbox-community/netbox/issues/13433) - User field on API token form should be required
* [#13434](https://github.com/netbox-community/netbox/issues/13434) - Randomly generate initial keys prior to the creation of new tokens
* [#13437](https://github.com/netbox-community/netbox/issues/13437) - Display bookmark button only for relevant objects
---
## v3.6-beta1 (2023-08-02)
## v3.6.0 (2023-08-30)
### Breaking Changes
@@ -23,12 +8,13 @@
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
* The `device` and `device_id` filter for interfaces will no longer include interfaces from virtual chassis peers. Two new filters, `virtual_chassis_member` and `virtual_chassis_member_id`, have been introduced to match all interfaces belonging to the specified device's virtual chassis (if any).
* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
* Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
### New Features
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
#### Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
@@ -72,6 +58,7 @@ Tags may now be restricted to use with designated object types. Tags that have n
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#11478](https://github.com/netbox-community/netbox/issues/11478) - Introduce `virtual_chassis_member` filter for interfaces & restore default behavior for `device` filter
* [#11519](https://github.com/netbox-community/netbox/issues/11519) - Add a SQL index for IP address host values to optimize queries
* [#11732](https://github.com/netbox-community/netbox/issues/11732) - Prevent inadvertent overwriting of object attributes by competing users
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
@@ -84,6 +71,12 @@ Tags may now be restricted to use with designated object types. Tags that have n
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Bug Fixes
* [#13513](https://github.com/netbox-community/netbox/issues/13513) - Prevent exception when rendering bookmarks widget for anonymous user
* [#13599](https://github.com/netbox-community/netbox/issues/13599) - Fix errant counter increments when editing device/VM components
* [#13605](https://github.com/netbox-community/netbox/issues/13605) - Optimize cached counter migrations to avoid excessive memory consumption
### Other Changes
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.

View File

@@ -125,12 +125,13 @@ class GitBackend(DataBackend):
}
if self.url_scheme in ('http', 'https'):
clone_args.update(
{
"username": self.params.get('username'),
"password": self.params.get('password'),
}
)
if self.params.get('username'):
clone_args.update(
{
"username": self.params.get('username'),
"password": self.params.get('password'),
}
)
logger.debug(f"Cloning git repo: {self.url}")
try:

View File

@@ -25,4 +25,7 @@ urlpatterns = (
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
# Configuration
path('config/', views.ConfigView.as_view(), name='config'),
)

View File

@@ -1,6 +1,7 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from extras.models import ConfigRevision
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from utilities.utils import count_related
@@ -141,3 +142,14 @@ class JobBulkDeleteView(generic.BulkDeleteView):
queryset = Job.objects.all()
filterset = filtersets.JobFilterSet
table = tables.JobTable
#
# Config Revisions
#
class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
return self.queryset.first()

View File

@@ -738,12 +738,12 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
# Related object counts
interface_count = serializers.IntegerField(read_only=True)

View File

@@ -1462,17 +1462,15 @@ class InterfaceFilterSet(
PathEndpointFilterSet,
CommonInterfaceFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members
device = MultiValueCharFilter(
method='filter_device',
virtual_chassis_member = MultiValueCharFilter(
method='filter_virtual_chassis_member',
field_name='name',
label=_('Device'),
label=_('Virtual Chassis Interfaces for Device')
)
device_id = MultiValueNumberFilter(
method='filter_device_id',
virtual_chassis_member_id = MultiValueNumberFilter(
method='filter_virtual_chassis_member',
field_name='pk',
label=_('Device (ID)'),
label=_('Virtual Chassis Interfaces for Device (ID)')
)
kind = django_filters.CharFilter(
method='filter_kind',
@@ -1540,23 +1538,11 @@ class InterfaceFilterSet(
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
]
def filter_device(self, queryset, name, value):
def filter_virtual_chassis_member(self, queryset, name, value):
try:
devices = Device.objects.filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
def filter_device_id(self, queryset, name, id_list):
# Include interfaces belonging to peer virtual chassis members
vc_interface_ids = []
try:
devices = Device.objects.filter(pk__in=id_list)
for device in devices:
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
for device in Device.objects.filter(**{f'{name}__in': value}):
vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()

View File

@@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Position'),
required=False,
help_text=_("The lowest-numbered unit occupied by the device"),
localize=True,
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
attrs={
'disabled-indicator': 'device',
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
}
},
)
)
device_type = DynamicModelChoiceField(
@@ -1110,7 +1111,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('Parent interface'),
query_params={
'device_id': '$device',
'virtual_chassis_member_id': '$device',
}
)
bridge = DynamicModelChoiceField(
@@ -1118,7 +1119,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('Bridged interface'),
query_params={
'device_id': '$device',
'virtual_chassis_member_id': '$device',
}
)
lag = DynamicModelChoiceField(
@@ -1126,7 +1127,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('LAG interface'),
query_params={
'device_id': '$device',
'virtual_chassis_member_id': '$device',
'type': 'lag',
}
)

View File

@@ -6,7 +6,7 @@ import utilities.fields
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
devices = list(Device.objects.all().annotate(
devices = Device.objects.annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
@@ -17,7 +17,7 @@ def recalculate_device_counts(apps, schema_editor):
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
))
)
for device in devices:
device.console_port_count = device._console_port_count
@@ -42,7 +42,7 @@ def recalculate_device_counts(apps, schema_editor):
'device_bay_count',
'module_bay_count',
'inventory_item_count',
])
], batch_size=100)
class Migration(migrations.Migration):

View File

@@ -7,12 +7,12 @@ import utilities.fields
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
class Migration(migrations.Migration):

View File

@@ -2822,11 +2822,56 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis(name='Virtual Chassis')
virtual_chassis.save()
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(
name='Device 1A',
device_type=device_types[0],
role=roles[0],
site=sites[0],
location=locations[0],
rack=racks[0],
virtual_chassis=virtual_chassis,
vc_position=1,
vc_priority=1
),
Device(
name='Device 1B',
device_type=device_types[2],
role=roles[2],
site=sites[2],
location=locations[2],
rack=racks[2],
virtual_chassis=virtual_chassis,
vc_position=2,
vc_priority=1
),
Device(
name='Device 2',
device_type=device_types[1],
role=roles[1],
site=sites[1],
location=locations[1],
rack=racks[1]
),
Device(
name='Device 3',
device_type=device_types[2],
role=roles[2],
site=sites[2],
location=locations[2],
rack=racks[2]
),
# For cable connections
Device(
name=None,
device_type=device_types[2],
role=roles[2],
site=sites[3]
),
)
Device.objects.bulk_create(devices)
@@ -2834,6 +2879,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
ModuleBay(device=devices[3], name='Module Bay 4'),
)
ModuleBay.objects.bulk_create(module_bays)
@@ -2841,6 +2887,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
Module(device=devices[3], module_bay=module_bays[3], module_type=module_type),
)
Module.objects.bulk_create(modules)
@@ -2853,16 +2900,11 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
# Virtual Device Context Creation
vdcs = (
VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
VirtualDeviceContext(device=devices[4], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
VirtualDeviceContext(device=devices[4], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
)
VirtualDeviceContext.objects.bulk_create(vdcs)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(
device=devices[0],
@@ -2885,6 +2927,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Interface(
device=devices[1],
module=modules[1],
name='VC Chassis Interface',
type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True
),
Interface(
device=devices[2],
module=modules[2],
name='Interface 2',
label='B',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
@@ -2901,8 +2950,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[2],
module=modules[2],
device=devices[3],
module=modules[3],
name='Interface 3',
label='C',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
@@ -2919,7 +2968,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
device=devices[4],
name='Interface 4',
label='D',
type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2932,7 +2981,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
device=devices[4],
name='Interface 5',
label='E',
type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2941,7 +2990,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
tx_power=40
),
Interface(
device=devices[3],
device=devices[4],
name='Interface 6',
label='F',
type=InterfaceTypeChoices.TYPE_OTHER,
@@ -2950,7 +2999,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
tx_power=40
),
Interface(
device=devices[3],
device=devices[4],
name='Interface 7',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_AP,
@@ -2959,7 +3008,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_channel_width=22
),
Interface(
device=devices[3],
device=devices[4],
name='Interface 8',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_STATION,
@@ -2977,8 +3026,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
interfaces[7].vdcs.set([vdcs[1]])
# Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
# Third pair is not connected
def test_name(self):
@@ -2991,7 +3040,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -3011,7 +3060,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
@@ -3116,6 +3165,14 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_chassis_member(self):
# Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
params = {'virtual_chassis_member_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'virtual_chassis_member': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
@@ -3125,23 +3182,23 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_kind(self):
params = {'kind': 'physical'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
params = {'kind': 'virtual'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

View File

@@ -398,32 +398,8 @@ class SiteView(generic.ObjectView):
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
)
locations = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'location',
'rack_count',
cumulative=True
)
locations = Location.objects.add_related_count(
locations,
Device,
'location',
'device_count',
cumulative=True
).restrict(request.user, 'view').filter(site=instance)
nonracked_devices = Device.objects.filter(
site=instance,
rack__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
return {
'related_models': related_models,
'locations': locations,
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
'total_nonracked_devices_count': nonracked_devices.count(),
}
@@ -495,16 +471,8 @@ class LocationView(generic.ObjectView):
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
)
nonracked_devices = Device.objects.filter(
location=instance,
rack__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
return {
'related_models': related_models,
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
'total_nonracked_devices_count': nonracked_devices.count(),
}
@@ -2055,7 +2023,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext',
weight=2000
)

View File

@@ -346,13 +346,16 @@ class BookmarksWidget(DashboardWidget):
def render(self, request):
from extras.models import Bookmark
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
conent_types = ContentType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types)
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]
if request.user.is_anonymous:
bookmarks = list()
else:
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
conent_types = ContentType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types)
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]
return render_to_string(self.template_name, {
'bookmarks': bookmarks,

View File

@@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
(_('Data'), ('data_source_id', 'data_file_id')),
(_('Attributes'), ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
(_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
'source_id': '$data_source_id'
}
)
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
required=False
required=False,
label=_('Content types')
)
mime_type = forms.CharField(
required=False,

View File

@@ -490,7 +490,9 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
(_('Validation'), ('CUSTOM_VALIDATORS',)),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
(_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
)),
(_('Config Revision'), ('comment',))
)
@@ -524,6 +526,8 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
elif value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
if type(value) in (tuple, list):
value = ', '.join(value)
self.fields[param.name].initial = value
if is_static:
self.fields[param.name].disabled = True

View File

@@ -69,10 +69,7 @@ class Command(BaseCommand):
if not kwargs['lazy']:
self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush()
content_types = [
ContentType.objects.get_for_model(model) for model in indexers.keys()
]
deleted_count = search_backend.clear(content_types)
deleted_count = search_backend.clear()
self.stdout.write(f'{deleted_count} entries deleted.')
# Index models

View File

@@ -1,7 +1,6 @@
import json
import urllib.parse
from django.contrib import admin
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -12,7 +11,7 @@ from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
@@ -724,7 +723,9 @@ class ConfigRevision(models.Model):
verbose_name_plural = _('config revisions')
def __str__(self):
return f'Config revision #{self.pk} ({self.created})'
if self.is_active:
return gettext('Current configuration')
return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item):
if item in self.data:
@@ -742,6 +743,6 @@ class ConfigRevision(models.Model):
cache.set('config_version', self.pk, None)
activate.alters_data = True
@admin.display(boolean=True)
@property
def is_active(self):
return cache.get('config_version') == self.pk

View File

@@ -11,6 +11,7 @@ from netbox.search import register_search
from .navigation import *
from .registration import *
from .templates import *
from .utils import *
# Initialize plugin registry
registry['plugins'].update({

View File

@@ -401,23 +401,23 @@ class BaseScript:
def log_debug(self, message):
self.logger.log(logging.DEBUG, message)
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
def log_success(self, message):
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
def log_info(self, message):
self.logger.log(logging.INFO, message)
self.log.append((LogLevelChoices.LOG_INFO, message))
self.log.append((LogLevelChoices.LOG_INFO, str(message)))
def log_warning(self, message):
self.logger.log(logging.WARNING, message)
self.log.append((LogLevelChoices.LOG_WARNING, message))
self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
def log_failure(self, message):
self.logger.log(logging.ERROR, message)
self.log.append((LogLevelChoices.LOG_FAILURE, message))
self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
# Convenience functions

View File

@@ -1109,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase):
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
)
Site.objects.bulk_create(sites)
# Simulate *creation* changelog records for two of the sites
request_id = uuid.uuid4()
cls.create_request_id = request_id
objectchanges = (
ObjectChange(
changed_object_type=content_type,
@@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
# Simulate *update* changelog records for two of the sites
request_id = uuid.uuid4()
cls.update_request_id = request_id
objectchanges = (
ObjectChange(
changed_object_type=content_type,
@@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
)
ObjectChange.objects.bulk_create(objectchanges)
# Simulate *create* and *update* changelog records for two of the sites
request_id = uuid.uuid4()
cls.create_update_request_id = request_id
objectchanges = (
ObjectChange(
changed_object_type=content_type,
changed_object_id=sites[2].pk,
action=ObjectChangeActionChoices.ACTION_CREATE,
request_id=request_id
),
ObjectChange(
changed_object_type=content_type,
changed_object_id=sites[3].pk,
action=ObjectChangeActionChoices.ACTION_UPDATE,
request_id=request_id
),
)
ObjectChange.objects.bulk_create(objectchanges)
def test_created_by_request(self):
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id
params = {'created_by_request': request_id}
params = {'created_by_request': self.create_request_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.queryset.count(), 3)
self.assertEqual(self.queryset.count(), 4)
def test_updated_by_request(self):
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id
params = {'updated_by_request': request_id}
params = {'updated_by_request': self.update_request_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.queryset.count(), 3)
self.assertEqual(self.queryset.count(), 4)
def test_modified_by_request(self):
params = {'modified_by_request': self.create_update_request_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.queryset.count(), 4)

View File

@@ -46,6 +46,21 @@ class CustomFieldListView(generic.ObjectListView):
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.select_related('choice_set')
def get_extra_context(self, request, instance):
related_models = ()
for content_type in instance.content_types.all():
related_models += (
content_type.model_class().objects.restrict(request.user, 'view').exclude(
Q(**{f'custom_field_data__{instance.name}': ''}) |
Q(**{f'custom_field_data__{instance.name}': None})
),
)
return {
'related_models': related_models
}
@register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView):

View File

@@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
choices=IPRangeStatusChoices,
null_value=None
)
parent = MultiValueCharFilter(
method='search_by_parent',
label=_('Parent prefix'),
)
class Meta:
model = IPRange
@@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
except ValidationError:
return queryset.none()
def search_by_parent(self, queryset, name, value):
if not value:
return queryset
q = Q()
for prefix in value:
try:
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
except (AddrFormatError, ValueError):
return queryset.none()
return queryset.filter(q)
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
family = django_filters.NumberFilter(

View File

@@ -592,9 +592,11 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
# if this is an update we might not have interface or vlan in the form data
if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

View File

@@ -118,6 +118,12 @@ class VLANGroup(OrganizationalModel):
return available_vids[0]
return None
def get_child_vlans(self):
"""
Return all VLANs within this group.
"""
return VLAN.objects.filter(group=self).order_by('vid')
class VLAN(PrimaryModel):
"""

View File

@@ -10,7 +10,6 @@ from ipam.models import *
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup
from rest_framework import serializers
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -807,6 +806,12 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all()

View File

@@ -897,21 +897,8 @@ class VLANGroupView(generic.ObjectView):
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
# TODO: Replace with embedded table
vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
'tenant', 'site', 'role',
).order_by('vid')
vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk')
vlans_table.configure(request)
return {
'related_models': related_models,
'vlans_table': vlans_table,
}
@@ -944,6 +931,30 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.VLANGroupTable
@register_model_view(VLANGroup, 'vlans')
class VLANGroupVLANsView(generic.ObjectChildrenView):
queryset = VLANGroup.objects.all()
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
permission='ipam.view_vlan',
weight=500
)
def get_children(self, request, parent):
return parent.get_child_vlans().restrict(request.user, 'view').prefetch_related(
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
'tenant', 'site', 'role',
)
def prep_table_data(self, request, queryset, parent):
return add_available_vlans(parent.get_child_vlans(), parent)
#
# FHRP groups
#

View File

@@ -102,7 +102,6 @@ PARAMS = (
description=_("Default voltage for powerfeeds"),
field=forms.IntegerField
),
ConfigParam(
name='POWERFEED_DEFAULT_AMPERAGE',
label=_('Powerfeed amperage'),
@@ -110,7 +109,6 @@ PARAMS = (
description=_("Default amperage for powerfeeds"),
field=forms.IntegerField
),
ConfigParam(
name='POWERFEED_DEFAULT_MAX_UTILIZATION',
label=_('Powerfeed max utilization'),

View File

@@ -246,18 +246,22 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
updated_by_request = django_filters.UUIDFilter(
method='filter_by_request'
)
modified_by_request = django_filters.UUIDFilter(
method='filter_by_request'
)
def filter_by_request(self, queryset, name, value):
content_type = ContentType.objects.get_for_model(self.Meta.model)
action = {
'created_by_request': ObjectChangeActionChoices.ACTION_CREATE,
'updated_by_request': ObjectChangeActionChoices.ACTION_UPDATE,
'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]),
}.get(name)
request_id = value
pks = ObjectChange.objects.filter(
action,
changed_object_type=content_type,
action=action,
request_id=request_id
request_id=request_id,
).values_list('changed_object_id', flat=True)
return queryset.filter(pk__in=pks)

View File

@@ -406,6 +406,11 @@ ADMIN_MENU = Menu(
MenuGroup(
label=_('Configuration'),
items=(
MenuItem(
link='core:config',
link_text=_('Current Config'),
permissions=['extras.view_configrevision']
),
MenuItem(
link='extras:configrevision_list',
link_text=_('Config Revisions'),

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6-beta2'
VERSION = '3.6.0'
# Hostname
HOSTNAME = platform.node()

View File

@@ -4,6 +4,7 @@ from urllib.parse import quote
import django_tables2 as tables
from django.conf import settings
from django.contrib.auth.context_processors import auth
from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.template import Context, Template
@@ -517,24 +518,32 @@ class CustomLinkColumn(tables.Column):
super().__init__(*args, **kwargs)
def render(self, record):
try:
rendered = self.customlink.render({
'object': record,
def _render_customlink(self, record, table):
context = {
'object': record,
'debug': settings.DEBUG,
}
if request := getattr(table, 'context', {}).get('request'):
# If the request is available, include it as context
context.update({
'request': request,
**auth(request),
})
if rendered:
return self.customlink.render(context)
def render(self, record, table, **kwargs):
try:
if rendered := self._render_customlink(record, table):
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
except Exception as e:
error_text = _('Error')
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
return ''
def value(self, record):
def value(self, record, table, **kwargs):
try:
rendered = self.customlink.render({
'object': record,
})
if rendered:
if rendered := self._render_customlink(record, table):
return rendered['link']
except Exception:
pass

View File

@@ -465,7 +465,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
messages.success(request, msg)
view_name = get_viewname(model, action='list')
results_url = f"{reverse(view_name)}?created_by_request={request.id}"
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
return redirect(results_url)
except (AbortTransaction, ValidationError):

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

@@ -6,7 +6,7 @@
"license": "Apache-2.0",
"private": true,
"dependencies": {
"graphiql": "1.4.1",
"graphiql": "1.8.9",
"graphql": ">= v14.5.0 <= 15.5.0",
"react": "17.0.2",
"react-dom": "17.0.2",

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,15 @@
{% load helpers %}
{% block bulk_edit_controls %}
{{ block.super }}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"

View File

@@ -1,83 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h5 class="card-header">
{% trans "Non-Racked Devices" %}
</h5>
<div class="card-body">
{% if nonracked_devices %}
<table class="table table-hover">
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Role" %}</th>
<th>{% trans "Type" %}</th>
<th colspan="2">{% trans "Parent Device" %}</th>
</tr>
{% for device in nonracked_devices %}
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
<td>
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
</td>
<td>{{ device.role }}</td>
<td>{{ device.device_type }}</td>
{% if device.parent_bay %}
<td>{{ device.parent_bay.device|linkify }}</td>
<td>{{ device.parent_bay }}</td>
{% else %}
<td colspan="2" class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% if total_nonracked_devices_count > nonracked_devices.count %}
{% if object|meta:'verbose_name' == 'site' %}
<div class="text-muted">
{% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
Displaying {{ count }} of {{ total }} devices
{% endblocktrans %}
(<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
</div>
{% elif object|meta:'verbose_name' == 'location' %}
<div class="text-muted">
{% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
Displaying {{ count }} of {{ total }} devices
{% endblocktrans %}
(<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
</div>
{% endif %}
{% endif %}
{% else %}
<div class="text-muted">
{% trans "None" %}
</div>
{% endif %}
</div>
{% if perms.dcim.add_device %}
{% if object|meta:'verbose_name' == 'rack' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add a Non-Racked Device" %}
</a>
</div>
{% elif object|meta:'verbose_name' == 'site' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add a Non-Racked Device" %}
</a>
</div>
{% elif object|meta:'verbose_name' == 'location' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add a Non-Racked Device" %}
</a>
</div>
{% endif %}
{% endif %}
</div>

View File

@@ -66,7 +66,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
@@ -79,6 +78,27 @@
hx-get="{% url 'dcim:location_list' %}?parent_id={{ object.pk }}"
hx-trigger="load"
></div>
{% if perms.dcim.add_location %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
</a>
</div>
{% endif %}
</div>
<div class="card">
<h5 class="card-header">Non-Racked Devices</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
hx-trigger="load"
></div>
{% if perms.dcim.add_device %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
</a>
</div>
{% endif %}
</div>
{% plugin_full_width_page object %}
</div>

View File

@@ -132,56 +132,40 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
<div class="card">
<h5 class="card-header">{% trans "Locations" %}</h5>
<div class='card-body'>
{% if locations %}
<table class="table table-hover">
<tr>
<th>{% trans "Location" %}</th>
<th>{% trans "Racks" %}</th>
<th>{% trans "Devices" %}</th>
<th></th>
</tr>
{% for location in locations %}
<tr>
<td>
{% for i in location.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
{{ location|linkify }}
</td>
<td>
<a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location.rack_count }}</a>
</td>
<td>
<a href="{% url 'dcim:device_list' %}?location_id={{ location.pk }}">{{ location.device_count }}</a>
</td>
<td class="text-end noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ location.pk }}" class="btn btn-sm btn-primary" title="{% trans "View Elevations" %}">
<i class="mdi mdi-server"></i>
</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
{% if perms.dcim.add_location %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a location" %}
</a>
</div>
{% endif %}
</div>
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% include 'dcim/inc/nonracked_devices.html' %}
<div class="card">
<h5 class="card-header">Locations</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:location_list' %}?site_id={{ object.pk }}"
hx-trigger="load"
></div>
{% if perms.dcim.add_location %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
</a>
</div>
{% endif %}
</div>
<div class="card">
<h5 class="card-header">Non-Racked Devices</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
hx-trigger="load"
></div>
{% if perms.dcim.add_device %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
</a>
</div>
{% endif %}
</div>
{% plugin_full_width_page object %}
</div>
</div>

View File

@@ -14,6 +14,13 @@
<div class="controls">
<div class="control-group">
{% plugin_buttons object %}
{% if object.is_active and perms.extras.add_configrevision %}
{% url 'extras:configrevision_add' as edit_url %}
{% include "buttons/edit.html" with url=edit_url %}
{% endif %}
{% if not object.is_active and perms.extras.delete_configrevision %}
{% delete_button object %}
{% endif %}
</div>
<div class="control-group">
{% custom_links object %}
@@ -23,17 +30,17 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Rack Elevation" %}</h5>
<h5 class="card-header">{% trans "Rack Elevations" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack elevation default unit height" %}:</th>
<th scope="row">{% trans "Default unit height" %}</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack elevation default unit width" %}:</th>
<th scope="row">{% trans "Default unit width" %}</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
</tr>
</table>
@@ -41,19 +48,19 @@
</div>
<div class="card">
<h5 class="card-header">{% trans "Power" %}</h5>
<h5 class="card-header">{% trans "Power Feeds" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Powerfeed default voltage" %}:</th>
<th scope="row">{% trans "Default voltage" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Powerfeed default amperage" %}:</th>
<th scope="row">{% trans "Default amperage" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Powerfeed default max utilization" %}:</th>
<th scope="row">{% trans "Default max utilization" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
</tr>
</table>
@@ -65,11 +72,11 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Enforce global unique" %}:</th>
<th scope="row">{% trans "Enforce global unique" %}</th>
<td>{{ object.data.ENFORCE_GLOBAL_UNIQUE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Prefer IPv4" %}:</th>
<th scope="row">{% trans "Prefer IPv4" %}</th>
<td>{{ object.data.PREFER_IPV4 }}</td>
</tr>
</table>
@@ -81,8 +88,8 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Allowed URL schemes" %}:</th>
<td>{{ object.data.ALLOWED_URL_SCHEMES }}</td>
<th scope="row">{% trans "Allowed URL schemes" %}</th>
<td>{{ object.data.ALLOWED_URL_SCHEMES|join:", "|placeholder }}</td>
</tr>
</table>
</div>
@@ -93,39 +100,35 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Login banner" %}:</th>
<th scope="row">{% trans "Login banner" %}</th>
<td>{{ object.data.BANNER_LOGIN }}</td>
</tr>
<tr>
<th scope="row">{% trans "Maintenance banner" %}:</th>
<th scope="row">{% trans "Maintenance banner" %}</th>
<td>{{ object.data.BANNER_MAINTENANCE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Top banner" %}:</th>
<th scope="row">{% trans "Top banner" %}</th>
<td>{{ object.data.BANNER_TOP }}</td>
</tr>
<tr>
<th scope="row">{% trans "Bottom banner" %}:</th>
<th scope="row">{% trans "Bottom banner" %}</th>
<td>{{ object.data.BANNER_BOTTOM }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Pagination" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Paginate count" %}:</th>
<th scope="row">{% trans "Paginate count" %}</th>
<td>{{ object.data.PAGINATE_COUNT }}</td>
</tr>
<tr>
<th scope="row">{% trans "Max page size" %}:</th>
<th scope="row">{% trans "Max page size" %}</th>
<td>{{ object.data.MAX_PAGE_SIZE }}</td>
</tr>
</table>
@@ -137,8 +140,8 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Custom validators" %}:</th>
<td>{{ object.data.CUSTOM_VALIDATORS }}</td>
<th scope="row">{% trans "Custom validators" %}</th>
<td>{{ object.data.CUSTOM_VALIDATORS|placeholder }}</td>
</tr>
</table>
</div>
@@ -149,8 +152,8 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Default user preferences" %}:</th>
<td>{{ object.data.DEFAULT_USER_PREFERENCES }}</td>
<th scope="row">{% trans "Default user preferences" %}</th>
<td>{{ object.data.DEFAULT_USER_PREFERENCES|placeholder }}</td>
</tr>
</table>
</div>
@@ -161,23 +164,23 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Maintenance mode" %}:</th>
<th scope="row">{% trans "Maintenance mode" %}</th>
<td>{{ object.data.MAINTENANCE_MODE }}</td>
</tr>
<tr>
<th scope="row">{% trans "GraphQL enabled" %}:</th>
<th scope="row">{% trans "GraphQL enabled" %}</th>
<td>{{ object.data.GRAPHQL_ENABLED }}</td>
</tr>
<tr>
<th scope="row">{% trans "Changelog retention" %}:</th>
<th scope="row">{% trans "Changelog retention" %}</th>
<td>{{ object.data.CHANGELOG_RETENTION }}</td>
</tr>
<tr>
<th scope="row">{% trans "Job retention" %}:</th>
<th scope="row">{% trans "Job retention" %}</th>
<td>{{ object.data.JOB_RETENTION }}</td>
</tr>
<tr>
<th scope="row">{% trans "Maps URL" %}:</th>
<th scope="row">{% trans "Maps URL" %}</th>
<td>{{ object.data.MAPS_URL }}</td>
</tr>
</table>
@@ -185,14 +188,9 @@
</div>
<div class="card">
<h5 class="card-header">{% trans "Config Revision" %}</h5>
<h5 class="card-header">{% trans "Comment" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Comment" %}:</th>
<td>{{ object.comment }}</td>
</tr>
</table>
{{ object.comment|placeholder }}
</div>
</div>

View File

@@ -125,6 +125,24 @@
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Related Objects</h5>
<ul class="list-group list-group-flush">
{% for qs in related_models %}
<a class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
{% if count %}
<span class="badge bg-primary rounded-pill">{{ count }}</span>
{% else %}
<span class="badge bg-light rounded-pill">&mdash;</span>
{% endif %}
{% endwith %}
</a>
{% endfor %}
</ul>
</div>
{% plugin_right_page object %}
</div>
</div>

View File

@@ -46,8 +46,10 @@ Context:
{% block subtitle %}
<div class="object-subtitle">
<span>{% trans "Created" %} {{ object.created|annotated_date }}</span>
<span class="separator">&middot;</span>
<span>{% trans "Updated" %} <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> {% trans "ago" %}</span>
{% if object.last_updated %}
<span class="separator">&middot;</span>
<span>{% trans "Updated" %} <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> {% trans "ago" %}</span>
{% endif %}
</div>
{% endblock subtitle %}

View File

@@ -59,15 +59,4 @@
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "VLANs" %}</h5>
<div class="card-body table-responsive">
{% render_table vlans_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=vlans_table.paginator page=vlans_table.page %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -60,7 +60,13 @@ class TokenProvisionView(APIView):
"""
permission_classes = []
# @extend_schema(methods=["post"], responses={201: serializers.TokenSerializer})
@extend_schema(
request=serializers.TokenProvisionSerializer,
responses={
201: serializers.TokenSerializer,
401: OpenApiTypes.OBJECT,
}
)
def post(self, request):
serializer = serializers.TokenProvisionSerializer(data=request.data)
serializer.is_valid()

View File

@@ -2,7 +2,7 @@ from django.db.models import Q
OBJECTPERMISSION_OBJECT_TYPES = Q(
~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) |
~Q(app_label__in=['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) |
Q(app_label='auth', model__in=['group', 'user']) |
Q(app_label='users', model__in=['objectpermission', 'token'])
)

View File

@@ -66,7 +66,7 @@ class Migration(migrations.Migration):
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
('constraints', models.JSONField(blank=True, null=True)),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
('object_types', models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(('app_label__in', ['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), _negated=True), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='contenttypes.ContentType')),
('object_types', models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(('app_label__in', ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), _negated=True), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='contenttypes.ContentType')),
('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
],
options={

View File

@@ -20,7 +20,8 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
lte='lte',
lt='lt',
gte='gte',
gt='gt'
gt='gt',
empty='isnull',
)
FILTER_NEGATION_LOOKUP_MAP = dict(
@@ -45,6 +46,10 @@ HTTP_REQUEST_META_SAFE_COPY = [
'HTTP_REFERER',
'HTTP_USER_AGENT',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED_HOST',
'HTTP_X_FORWARDED_PORT',
'HTTP_X_FORWARDED_PROTO',
'HTTP_X_REAL_IP',
'QUERY_STRING',
'REMOTE_ADDR',
'REMOTE_HOST',

View File

@@ -27,7 +27,7 @@ def update_counter(model, pk, counter_name, value):
# Signal handlers
#
def post_save_receiver(sender, instance, **kwargs):
def post_save_receiver(sender, instance, created, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is created or modified.
"""
@@ -39,7 +39,7 @@ def post_save_receiver(sender, instance, **kwargs):
# Update the counters on the old and/or new parents as needed
if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1)
if new_pk is not None:
if new_pk is not None and (old_pk or created):
update_counter(parent_model, new_pk, counter_name, 1)

View File

@@ -105,6 +105,10 @@ class RestrictedGenericForeignKey(GenericForeignKey):
# We avoid looking for values if either ct_id or fkey value is None
ct_id = getattr(instance, ct_attname)
if ct_id is not None:
# Check if the content type actually exists
if not self.get_content_type(id=ct_id, using=instance._state.db).model_class():
continue
fk_val = getattr(instance, self.fk_field)
if fk_val is not None:
fk_dict[ct_id].add(fk_val)
@@ -129,13 +133,14 @@ class RestrictedGenericForeignKey(GenericForeignKey):
if ct_id is None:
return None
else:
model = self.get_content_type(
if model := self.get_content_type(
id=ct_id, using=obj._state.db
).model_class()
return (
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
model,
)
).model_class():
return (
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
model,
)
return None
return (
ret_val,

View File

@@ -1,3 +1,4 @@
{% load l10n %}
<div class="progress">
<div
role="progressbar"
@@ -5,7 +6,7 @@
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
class="progress-bar {{ bar_class }}"
style="width: {{ utilization }}%;"
style="width: {{ utilization|unlocalize }}%;"
>
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
</div>

View File

@@ -29,13 +29,17 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 2)
self.assertEqual(device2.interface_count, 2)
Interface.objects.create(device=device1, name='Interface 5')
interface1 = Interface.objects.create(device=device1, name='Interface 5')
Interface.objects.create(device=device2, name='Interface 6')
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3)
interface1.save()
device1.refresh_from_db()
self.assertEqual(device1.interface_count, 3)
def test_interface_count_deletion(self):
"""
When a tracked object (Interface) is deleted the tracking counter should be updated.

View File

@@ -86,6 +86,10 @@ class DummyModel(models.Model):
charfield = models.CharField(
max_length=10
)
numberfield = models.IntegerField(
blank=True,
null=True
)
choicefield = models.IntegerField(
choices=(('A', 1), ('B', 2), ('C', 3))
)
@@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
"""
class DummyFilterSet(BaseFilterSet):
charfield = django_filters.CharFilter()
numberfield = django_filters.NumberFilter()
macaddressfield = MACAddressFilter()
modelchoicefield = django_filters.ModelChoiceFilter(
field_name='integerfield', # We're pretending this is a ForeignKey field
@@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
model = DummyModel
fields = (
'charfield',
'numberfield',
'choicefield',
'datefield',
'datetimefield',
@@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['charfield__iew'].exclude, False)
self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
self.assertEqual(self.filters['charfield__niew'].exclude, True)
self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
self.assertEqual(self.filters['charfield__empty'].exclude, False)
def test_number_filter(self):
self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
self.assertEqual(self.filters['numberfield'].lookup_expr, 'exact')
self.assertEqual(self.filters['numberfield'].exclude, False)
self.assertEqual(self.filters['numberfield__n'].lookup_expr, 'exact')
self.assertEqual(self.filters['numberfield__n'].exclude, True)
self.assertEqual(self.filters['numberfield__lt'].lookup_expr, 'lt')
self.assertEqual(self.filters['numberfield__lt'].exclude, False)
self.assertEqual(self.filters['numberfield__lte'].lookup_expr, 'lte')
self.assertEqual(self.filters['numberfield__lte'].exclude, False)
self.assertEqual(self.filters['numberfield__gt'].lookup_expr, 'gt')
self.assertEqual(self.filters['numberfield__gt'].exclude, False)
self.assertEqual(self.filters['numberfield__gte'].lookup_expr, 'gte')
self.assertEqual(self.filters['numberfield__gte'].exclude, False)
self.assertEqual(self.filters['numberfield__empty'].lookup_expr, 'isnull')
self.assertEqual(self.filters['numberfield__empty'].exclude, False)
def test_mac_address_filter(self):
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)

View File

@@ -7,12 +7,12 @@ import utilities.fields
def populate_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
vms = list(VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True)))
vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True))
for vm in vms:
vm.interface_count = vm._interface_count
VirtualMachine.objects.bulk_update(vms, ['interface_count'])
VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
class Migration(migrations.Migration):

View File

@@ -387,7 +387,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
base_template = 'virtualization/virtualmachine.html'
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext',
weight=2000
)

View File

@@ -21,13 +21,13 @@ graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.1.21
mkdocs-material==9.2.5
mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0
Pillow==10.0.0
psycopg[binary,pool]==3.1.10
PyYAML==6.0.1
sentry-sdk==1.29.2
sentry-sdk==1.30.0
social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3