Compare commits

...

88 Commits

Author SHA1 Message Date
Jeremy Stretch
e5cdd8f2b0 Release v4.2.9 2025-04-30 14:31:30 -04:00
Jeremy Stretch
6202ae1236 Closes #19358: Move release info from footer to nav menu (#19360) 2025-04-30 10:44:10 -07:00
Jeremy Stretch
15c7a19fb7 Fixes #19365: Omit plugin icon from page title 2025-04-30 13:14:15 -04:00
Martin Hauser
1141ddb22a feat(virtualization): Add Q-in-Q SVLAN to VMinterface template (#19345) 2025-04-30 08:50:28 -07:00
bctiemann
68e9da5cd9 Merge pull request #19333 from netbox-community/17405-plugin-icons
Fixes #17405: Added plugin icon to plugin list/detail
2025-04-30 10:48:42 -04:00
bctiemann
36d71ccdd1 Merge pull request #19251 from netbox-community/17613-columns
17613 Add column support for mobile devices
2025-04-30 09:15:20 -04:00
bctiemann
fadc358329 Merge pull request #19357 from netbox-community/17319-device-and-module-type-behind-tabs
Fixes #17319: Arrange device and module type fields behind tab in com…
2025-04-30 09:09:55 -04:00
bctiemann
5274b3d727 Merge pull request #19352 from netbox-community/19351-pin-actions
Closes #19351: Pin 3rd party GitHub actions to commit hash
2025-04-30 09:02:51 -04:00
bctiemann
c0e6168d34 Merge pull request #19347 from netbox-community/19346-redirect-checks
Fixes #19346: Ensure all redirect URLs are validated
2025-04-30 09:01:55 -04:00
github-actions
01da6186eb Update source translation strings 2025-04-30 05:02:08 +00:00
Markku Leiniö
0466c8ef9b Closes #19167: Update REST API examples (#19353)
* Closes #19167: Update REST API examples

* Fix missing description

---------

Co-authored-by: Markku Leiniö <markkuleinio@users.noreply.github.com>
2025-04-29 13:31:06 -04:00
Jason Novinger
964ae56d34 Fixes #17319: Arrange device and module type fields behind tab in component template forms 2025-04-29 12:16:05 -05:00
Jason Novinger
e2e42acf42 Add grayscale image filter
Hat tip to @pheus. Thanks!

I did end up leaving the filter function arguments as the Sass
processor complains when you try calling a filter function without
an argument. :/
2025-04-29 11:53:12 -05:00
Jason Novinger
32a4d743ee Fix KeyError 2025-04-29 11:43:36 -05:00
Jeremy Stretch
5342552054 Fixes #15739: Account for parallel cables when calculating total path length (#19356) 2025-04-29 11:32:43 -05:00
Jeremy Stretch
732f50d8da Closes #19351: Pin 3rd party GitHub actions to commit hash 2025-04-29 08:42:52 -04:00
github-actions
48a367c409 Update source translation strings 2025-04-29 05:02:15 +00:00
Jeremy Stretch
e44ad8af45 Fixes #19346: Ensure all redirect URLs are validated 2025-04-28 14:27:49 -04:00
Arthur Hanson
81dfaf0d67 18706 Fix VLAN Assignment checking (#19332)
* 18706 Fix VLAN assignment checking

* 18706 add tests

* 18706 review feedback
2025-04-28 11:45:01 -04:00
Renato Almeida de Oliveira
584fff90c7 Closes #18215: Create ScriptJobTable from JobTable and add it to ScriptSourceView (#19337) 2025-04-28 09:57:48 -05:00
Renato Almeida de Oliveira
e345ca2659 Add VirtualCircuit reference to ProviderView related models (#19335) 2025-04-28 07:54:10 -07:00
bctiemann
bdef00f3b0 Merge pull request #19310 from netbox-community/15971-preserve-null-filter
Fixes #15971: Preserve 'none' selection in filter form fields
2025-04-28 08:50:32 -04:00
bctiemann
f652dc7bda Merge pull request #19301 from renatoalmeidaoliveira/19229-cant-unset-a-device-interface-vlan-mode-without-an-error
Fixes: #19229 Validade interface mode changes
2025-04-28 08:48:20 -04:00
Arthur Hanson
18ac29fdd0 18334 add location, device, site to module filters (#19312)
* 18334 add location, device, site to module filters

* 18334 add location, device, site to module filters

* 18334 add tests

* 18334 fix tests

* 18334 add site-group
2025-04-28 08:46:38 -04:00
bctiemann
fdf42860aa Merge pull request #19282 from netbox-community/19281-fix-service-templates
Fixes #19281: Fix service template creation form
2025-04-28 08:45:53 -04:00
Jason Novinger
b31da39c4a Fix CSVModelMultipleChoiceField.clean assuming all values are str/None 2025-04-28 08:41:27 -04:00
Jeremy Stretch
0f4afbca2c Closes #17243: Include example of image upload using REST API (#19330) 2025-04-25 15:33:46 -05:00
Jason Novinger
9173a113b7 Update compiled static files with updated NPM deps 2025-04-25 14:07:03 -05:00
Jason Novinger
afc7b35af0 Added plugin icon to plugin list/detail 2025-04-25 13:50:36 -05:00
Jeremy Stretch
37cfc50202 Fixes #19322: Correct URL paths for bulk import views (#19323) 2025-04-25 12:20:25 -05:00
Jamie (Bear) Murphy
e8dd486132 adds related object to rack reservation changelogs (#19306) 2025-04-25 07:32:18 -07:00
github-actions
e58815bb1a Update source translation strings 2025-04-25 05:02:12 +00:00
bctiemann
bee004fc0c Fixes: #18717 - On delete signal handling, manually save the related object in a ManyToOneRel to trigger a change record (#19308)
* On delete signal handling, manually save the related object in a ManyToOneRel to trigger a change record

* Only set remote field to None if null=True on the relation
2025-04-24 14:24:20 -05:00
Jeremy Stretch
e1b2b4b536 Fixes #17676: Fix support for module bay creation when bulk importing module types (#19311) 2025-04-24 11:07:46 -07:00
Daniel Sheppard
f711e666c5 Fixes: #19228 - Fix ordered_scripts to only return ordered list of script objects (#19293) 2025-04-24 11:00:40 -07:00
Jeremy Stretch
47da880547 Fixes #15971: Preserve 'none' selection in filter form fields 2025-04-24 11:42:30 -04:00
mr1716
02f51bc11b Fixes #19255: Improved documentation consistency for configuration default values (#19258) 2025-04-24 09:57:55 -04:00
Martin Hauser
88dd7a16f8 feat(circuits): Use ColoredLabelColumn for type column
Refactors the 'type' column to use ColoredLabelColumn for improved
visual distinction, aligning it with the rack roles display.
Removes the now redundant 'linkify' attribute from the column
definition.
2025-04-24 08:51:42 -04:00
Arthur Hanson
d0c2e0e52b 16238 map custom-link button colors to tabler (#19290)
* 16238 map custom-link button colors to tabler

* 16238 map custom-link button colors to tabler

* 16238 backout temp code

* 16238 optimize
2025-04-24 08:40:14 -04:00
github-actions
983e544376 Update source translation strings 2025-04-24 05:02:08 +00:00
Renato Almeida de Oliveira Zaroubin
125bce84e4 Validade interface mode changes 2025-04-24 01:47:43 +00:00
Jeremy Stretch
fbf926204e Fixes #19204: Use DjangoJSONEncoder for Job data (#19297) 2025-04-23 15:57:48 -05:00
Arthur Hanson
0ce307c7fd 19217 debug toolbar (#19289)
* 19217 update and re-add django-debug-toolbar to INSTALLED_APPS

* 19217 remove debug if not collectstatic
2025-04-23 13:21:59 -05:00
Jeremy Stretch
6c60a4360b Fixes #19279: Fix missing status field for inventory item bulk creation (#19283) 2025-04-23 10:49:22 -07:00
Artem Kotik
2c3fe9700f Fixes #19110: Add filters by IP addresses to PrimaryIPFilterSet (#19160)
* Add primary ip filters by address

* Add tests for Device and VirtualDeviceContext models
2025-04-23 09:14:33 -05:00
Jeremy Stretch
deaff2dad8 Fixes #19281: Fix service template creation form 2025-04-23 08:47:54 -04:00
github-actions
cd3d91e7c7 Update source translation strings 2025-04-23 05:02:11 +00:00
Jeremy Stretch
d2e74e9d50 Release v4.2.8 2025-04-22 15:44:02 -04:00
bctiemann
02571130b2 Merge pull request #19259 from netbox-community/18961-vc-create-ignore-vc-members
Fixes #18961: Virtual chassis form should exclude members of other VCs when adding members
2025-04-22 14:36:12 -04:00
Jeremy Stretch
46a3ce2559 Fixes #19268: Restore editing conflict protection for several object forms (#19271) 2025-04-22 11:07:03 -07:00
Jeremy Stretch
1850c21714 Fixes #19265: Strip linebreaks from URLs for logging 2025-04-22 12:48:57 -04:00
Jeremy Stretch
13ddd5fd20 Fixes #19230: Allow label reuse when creating multiple components from a pattern (#19261) 2025-04-22 08:55:31 -07:00
Jeremy Stretch
60cdf89cad Fixes #19166: Fix bulk import support for base_choices (#19257) 2025-04-22 08:29:00 -07:00
Jeremy Stretch
77bfc40579 Closes #17136: Add read-only database support to the upgrade script (#19247) 2025-04-22 07:42:54 -07:00
Jeremy Stretch
2f8936d493 Fixes #18961: Virtual chassis form should exclude members of other VCs when adding members 2025-04-22 10:24:43 -04:00
bctiemann
e0b6a31504 Merge pull request #19256 from netbox-community/18959-cable-trace-terminations-ordering
Fixes #18959: Preserve ordering of terminations in cable traces
2025-04-22 10:08:13 -04:00
Jeremy Stretch
8567aa96e4 Remove obsolete comment 2025-04-22 09:42:17 -04:00
Jeremy Stretch
459c4bfd9d Fixes #18959: Preserve ordering of terminations in cable traces 2025-04-22 09:21:14 -04:00
Jeremy Stretch
918470a2bb Closes #19208: Ignore beta releases when fetching latest release tag 2025-04-22 08:18:23 -04:00
Arthur
c73cc0a36a 18500 fix check for cloning mixin 2025-04-22 08:14:32 -04:00
Renato Almeida de Oliveira Zaroubin
6b9b66aecb make INTERFACE_FHRPGROUPS use FHRPGROUPS _str_ representation 2025-04-22 08:06:24 -04:00
github-actions
b6d10ae6d8 Update source translation strings 2025-04-22 05:02:08 +00:00
Arthur
79ece657ec 17613 Add column support for mobile devices 2025-04-21 11:52:19 -07:00
Arthur
717b9d5232 17613 Add column support for mobile devices 2025-04-21 11:41:56 -07:00
Arthur
0fa98d3aef 17613 Add column support for mobile devices 2025-04-21 11:02:56 -07:00
Renato Almeida de Oliveira
7420c25687 Fixes: #18881 Site Groups are missing VLAN and VM related objects (#18932) 2025-04-21 09:11:01 -07:00
github-actions
248c94bd35 Update source translation strings 2025-04-16 05:02:15 +00:00
bctiemann
96cf8d14dc Merge pull request #19194 from netbox-community/17908-cable-trace-buttons
Closes #17908: Add trace buttons to terminations under cable view
2025-04-15 18:13:59 -04:00
bctiemann
2356a3c125 Merge pull request #19162 from alehaa/19040-copy-rendered-config
Fixes #19040: Add button to copy rendered config to clipboard
2025-04-15 18:00:47 -04:00
bctiemann
0d81007fdf Merge pull request #19178 from netbox-community/19139-tx-force-pull
Closes #19139: Use --force when invoking tx pull
2025-04-15 17:59:58 -04:00
atownson
c108c738ae Fixes #18669 - Populate custom field default values (#19115) 2025-04-15 13:26:03 -07:00
Martin Hauser
cac41cd093 Fixes #19196: Add Filtering by VLAN Translation Policy to Interface Filter Forms (#19199)
* feat(dcim): Add VLAN Translation Policy to Filter Form

Introduces support for VLAN Translation Policies in the Interface
Filter Form.

* feat(virtualization): Add VLAN Translation Policy to Filter Form

Introduces support for VLAN Translation Policies in the Interface
Filter Form.
2025-04-15 16:24:45 -04:00
Jeremy Stretch
27b26ec49c Fixes #19195: Language cookie should respect SESSION_COOKIE_SECURE value (#19197) 2025-04-15 13:03:33 -07:00
Jeremy Stretch
7c2776d721 Closes #19201: Include TS source with sourcemap (#19202) 2025-04-15 14:42:40 -05:00
Martin Hauser
1f93471659 Fixes #18978 - Allow filtering of Interfaces in the GUI by 802.1Q Mode (#19183)
* feat(dcim): Add VLAN mode filter to CommonInterface

Introduces a new FilterSet for VLAN mode in CommonInterfaceFilterSet.
This allows filtering interfaces based on their VLAN mode using defined
choices.

* feat(dcim): Add VLAN mode filter to Interface FilterForm

Add a field to InterfaceFilterSet to filter interfaces by 802.1Q VLAN
mode.

* feat(virtualization): Add VLAN mode filter to VMInterface

Add a field to VMInterfaceFilterSet to filter interfaces by 802.1Q VLAN
mode.

* fix(dcim): Correct mode filter parameter type in tests

Updates the `mode` filter parameter to accept a list instead of a single
value in `test_filtersets.py`. Ensures proper count assertion for
accurate test behavior.

* feat(virtualization): Add tests for VLAN mode filtering

Introduces tests to validate filtering by `mode` for VMInterface.
Ensures correct filtering for 802.1Q VLAN mode.

* refactor(virtualization): Reorganize FieldSets in FilterSets

Splits the 'Attributes' FieldSet into two distinct FieldSets for better
clarity: 'Attributes' and 'Addressing'. This improves form organization
and makes it more intuitive for users.
2025-04-15 13:47:51 -04:00
Jeremy Stretch
d3768feb31 Closes #17908: Add trace buttons to terminations under cable view 2025-04-15 11:41:00 -04:00
Jeremy Stretch
70cc7c7563 Fixes #19169: Remove horizontal padding from highlighted text (#19181) 2025-04-15 07:13:58 -07:00
Jeremy Stretch
8b091fb219 Fixes #19189: BaseScript.load_yaml() should use SafeLoader (#19190) 2025-04-15 08:58:13 -05:00
Martin Hauser
44cb1a9139 Fixes #19056 – Add Device filtering by Location slug (#19180)
* feat(dcim): Add filter by location slug for Device

Introduces a TreeNodeMultipleChoiceFilter for filtering locations by
slug. Enhances filtering flexibility in the Device model by supporting
both ID and slug lookups.

Fixes #19056

* feat(dcim): Add Device filtering by location slug in tests

Extend test cases to include filtering by location slug. Ensures the
FilterSet works correctly with slug-based queries for locations.

Fixes #19056
2025-04-15 08:22:59 -04:00
Martin Hauser
bb9b0b8f8a Fixes #18879 - Add prefix filtering by assigned VLAN Group (#19182)
* feat(ipam): Add VLAN group filters to IPAM FilterSet

Introduces filters for VLAN groups using both ID and slug fields.

* feat(ipam): Add VLAN group filter in IPAM FilterForm

Introduces a `vlan_group_id` filter to IPAM forms for filtering based on
VLAN groups.

* feat(ipam): Add VLAN group filtering to tests

Introduces tests for VLAN group filtering in FilterSets. This ensures
correct validation and behavior when filtering by VLAN group.
2025-04-15 07:59:32 -04:00
github-actions
785ad505ba Update source translation strings 2025-04-15 05:02:10 +00:00
Jeremy Stretch
8aacef60a3 Closes #19175: Remove outdated NetBox installation video (#19177) 2025-04-14 15:14:59 -05:00
Jeremy Stretch
d8fc052bbe Closes #19139: Use --force when invoking tx pull 2025-04-14 15:55:08 -04:00
Renato Almeida de Oliveira Zaroubin
1f79411878 Add GetRelatedModelsMixin to ClusterView 2025-04-14 15:39:49 -04:00
bctiemann
94d19e8f15 Remove #18095 from v4.2.5 release notes (#19165) 2025-04-14 07:26:37 -07:00
Alexander Haase
f337ef1134 Add button to copy rendered config 2025-04-13 18:02:21 +02:00
Jeremy Stretch
6ed41f6680 Fixes #18955: Include the "clear" button only for non-required dropdown fields (#18971)
* Include the 'clear' button only for non-required fields

* Apply the 'required' attribute for mandatory <select> fields

* Recompile static assets
2025-04-11 08:20:24 -04:00
github-actions
28e62d21a9 Update source translation strings 2025-04-11 05:02:06 +00:00
223 changed files with 13012 additions and 11428 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.2.7
placeholder: v4.2.9
validations:
required: true
- type: dropdown

View File

@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.2.7
placeholder: v4.2.9
validations:
required: true
- type: dropdown

View File

@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
issue-inactive-days: 90
pr-inactive-days: 30

View File

@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
uses: EndBug/add-and-commit@v9
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
add: 'netbox/translations/'
default_author: github_actions

View File

@@ -8,10 +8,7 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
# See: https://django-debug-toolbar.readthedocs.io/en/latest/changes.html#id1
# "Wrap SHOW_TOOLBAR_CALLBACK function with sync_to_async or async_to_sync to allow sync/async
# compatibility." breaks stawberry-graphql-django at version 0.52.0 (current)
django-debug-toolbar==5.0.1
django-debug-toolbar
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -135,8 +132,7 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
# Pinned to v0.52.0 for suspected upstream bug; see #18329
strawberry-graphql-django==0.52.0
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/
!!! tip "Dynamic Configuration Parameter"
Default: 50
Default: `50`
The default maximum number of objects to display per page within each list of objects.
@@ -79,7 +79,7 @@ The default maximum number of objects to display per page within each list of ob
!!! tip "Dynamic Configuration Parameter"
Default: 15
Default: `15`
The default value for the `amperage` field when creating new power feeds.
@@ -89,7 +89,7 @@ The default value for the `amperage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
Default: 80
Default: `80`
The default value (percentage) for the `max_utilization` field when creating new power feeds.
@@ -99,7 +99,7 @@ The default value (percentage) for the `max_utilization` field when creating new
!!! tip "Dynamic Configuration Parameter"
Default: 120
Default: `120`
The default value for the `voltage` field when creating new power feeds.
@@ -109,7 +109,7 @@ The default value for the `voltage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
Default: 22
Default: `22`
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
@@ -119,6 +119,6 @@ Default height (in pixels) of a unit within a rack elevation. For best results,
!!! tip "Dynamic Configuration Parameter"
Default: 220
Default: `220`
Default width (in pixels) of a unit within a rack elevation.

View File

@@ -2,7 +2,7 @@
## DEBUG
Default: False
Default: `False`
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
@@ -16,6 +16,6 @@ interface.
## DEVELOPER
Default: False
Default: `False`
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.

View File

@@ -2,7 +2,7 @@
## SENTRY_DSN
Default: None
Default: `None`
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
@@ -14,7 +14,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
## SENTRY_ENABLED
Default: False
Default: `False`
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
@@ -25,7 +25,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/)
## SENTRY_SAMPLE_RATE
Default: 1.0 (all)
Default: `1.0` (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
@@ -33,7 +33,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
## SENTRY_SEND_DEFAULT_PII
Default: False
Default: `False`
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
@@ -60,7 +60,7 @@ SENTRY_TAGS = {
## SENTRY_TRACES_SAMPLE_RATE
Default: 0 (disabled)
Default: `0` (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).

View File

@@ -4,7 +4,7 @@
!!! tip "Dynamic Configuration Parameter"
Default: True
Default: `True`
Setting this to False will disable the GraphQL API.
@@ -12,6 +12,6 @@ Setting this to False will disable the GraphQL API.
## GRAPHQL_MAX_ALIASES
Default: 10
Default: `10`
The maximum number of queries that a GraphQL API request may contain.

View File

@@ -55,7 +55,7 @@ Sets content for the top banner in the user interface.
## CENSUS_REPORTING_ENABLED
Default: True
Default: `True`
Enables anonymous census reporting. To opt out of census reporting, set this to False.
@@ -67,7 +67,7 @@ This data enables the project maintainers to estimate how many NetBox deployment
!!! tip "Dynamic Configuration Parameter"
Default: 90
Default: `90`
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely.
@@ -79,7 +79,7 @@ changes in the database indefinitely.
## CHANGELOG_SKIP_EMPTY_CHANGES
Default: True
Default: `True`
If enabled, a change log record will not be created when an object is updated without any changes to its existing field values.
@@ -100,7 +100,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
!!! tip "Dynamic Configuration Parameter"
Default: True
Default: `True`
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False.
@@ -128,7 +128,7 @@ The maximum amount (in bytes) of uploaded data that will be held in memory befor
!!! tip "Dynamic Configuration Parameter"
Default: 90
Default: `90`
The number of days to retain job results (scripts and reports). Set this to `0` to retain job results in the database indefinitely.
@@ -141,7 +141,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
!!! tip "Dynamic Configuration Parameter"
Default: False
Default: `False`
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
@@ -161,7 +161,7 @@ This specifies the URL to use when presenting a map of a physical location by st
!!! tip "Dynamic Configuration Parameter"
Default: 1000
Default: `1000`
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
@@ -169,7 +169,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi
## METRICS_ENABLED
Default: False
Default: `False`
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details.
@@ -179,7 +179,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
!!! tip "Dynamic Configuration Parameter"
Default: False
Default: `False`
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
@@ -203,7 +203,7 @@ If no queue is defined the queue named `default` will be used.
## RELEASE_CHECK_URL
Default: None (disabled)
Default: `None` (disabled)
This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.

View File

@@ -2,7 +2,7 @@
## PLUGINS
Default: Empty
Default: `[]`
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
@@ -13,7 +13,7 @@ A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins wil
## PLUGINS_CONFIG
Default: Empty
Default: `[]`
This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below:

View File

@@ -2,7 +2,7 @@
## ALLOW_TOKEN_RETRIEVAL
Default: True
Default: `True`
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
@@ -47,7 +47,7 @@ Although it is not recommended, the default validation rules can be disabled by
## CORS_ORIGIN_ALLOW_ALL
Default: False
Default: `False`
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
@@ -79,7 +79,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
## CSRF_COOKIE_SECURE
Default: False
Default: `False`
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -159,7 +159,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
## LOGIN_PERSISTENCE
Default: False
Default: `False`
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
@@ -169,7 +169,7 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
Default: True
Default: `True`
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
@@ -180,7 +180,7 @@ When enabled, only authenticated users are permitted to access any part of NetBo
## LOGIN_TIMEOUT
Default: 1209600 seconds (14 days)
Default: `1209600` seconds (14 days)
The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
@@ -196,7 +196,7 @@ The view name or URL to which a user is redirected after logging out.
## SECURE_HSTS_INCLUDE_SUBDOMAINS
Default: False
Default: `False`
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
@@ -204,7 +204,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
## SECURE_HSTS_PRELOAD
Default: False
Default: `False`
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
@@ -212,7 +212,7 @@ If true, the `preload` directive will be included in the HTTP Strict Transport S
## SECURE_HSTS_SECONDS
Default: 0
Default: `0`
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
@@ -220,7 +220,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
## SECURE_SSL_REDIRECT
Default: False
Default: `False`
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
@@ -239,7 +239,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
## SESSION_COOKIE_SECURE
Default: False
Default: `False`
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -247,6 +247,6 @@ If true, the cookie employed for session authentication will be marked as secure
## SESSION_FILE_PATH
Default: None
Default: `None`
HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path.

View File

@@ -2,7 +2,7 @@
## BASE_PATH
Default: None
Default: `None`
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
@@ -64,7 +64,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
## HTTP_PROXIES
Default: None
Default: `None`
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
@@ -89,7 +89,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
## ISOLATED_DEPLOYMENT
Default: False
Default: `False`
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
@@ -206,7 +206,7 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
## TIME_ZONE
Default: UTC
Default: `"UTC"`
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
@@ -214,6 +214,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
## TRANSLATION_ENABLED
Default: True
Default: `True`
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

View File

@@ -150,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
```no-highlight
tx pull
tx pull --force
```
Then, compile these portable (`.po`) files for use in the application:

View File

@@ -33,10 +33,10 @@ To download translated strings automatically, you'll need to:
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
```no-highlight
TX_TOKEN=$TOKEN tx pull
TX_TOKEN=$TOKEN tx pull --force
```
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.)
Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:

View File

@@ -246,7 +246,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Create a Python virtual environment
* Installs all required Python packages
* Run database schema migrations
* Run database schema migrations (skip with `--readonly`)
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
@@ -266,6 +266,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
!!! note
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
!!! note
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
## Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:

View File

@@ -5,8 +5,6 @@
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)

View File

@@ -124,17 +124,19 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
### Option B: Check Out a Git Release
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
```
sudo git fetch --tags
git describe --tags $(git rev-list --tags --max-count=1)
git ls-remote --tags https://github.com/netbox-community/netbox.git \
| grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \
| tail -n 1 \
| sed 's|refs/tags/||'
```
Check out the desired release by specifying its tag:
Check out the desired release by specifying its tag. For example:
```
sudo git checkout v4.2.0
sudo git checkout v4.2.7
```
## 4. Run the Upgrade Script
@@ -152,6 +154,9 @@ sudo ./upgrade.sh
sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
```
!!! note
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
This script performs the following actions:
* Destroys and rebuilds the Python virtual environment

View File

@@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w
### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this:
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
```
```no-highlight
GET /api/ipam/prefixes/13980/
```
```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
"display_url": "http://netbox/api/ipam/prefixes/13980/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"id": 3,
"url": "http://netbox/api/dcim/sites/17/",
"name": "Site 23A",
"slug": "site-23a"
},
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 3,
"scope": {
"id": 3,
"url": "http://netbox/api/dcim/sites/3/",
"display": "Site 23A",
"name": "Site 23A",
"slug": "site-23a",
"description": ""
},
"tenant": null,
"vlan": null,
"status": {
@@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/
"slug": "staging"
},
"is_pool": false,
"mark_utilized": false,
"description": "Example prefix",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2018-12-10",
"last_updated": "2019-03-01T20:02:46.173540Z"
"created": "2025-03-01T20:01:23.458302Z",
"last_updated": "2025-03-01T20:02:46.173540Z",
"children": 0,
"_depth": 0
}
```
The brief format is much more terse:
```
```no-highlight
GET /api/ipam/prefixes/13980/?brief=1
```
```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
"family": 4,
"prefix": "10.40.3.0/24"
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"description": "Example prefix",
"_depth": 0
}
```
@@ -400,25 +420,31 @@ curl -s -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/ipam/prefixes/ \
--data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.'
--data '{"prefix": "192.0.2.0/24", "scope_type": "dcim.site", "scope_id": 6}' | jq '.'
```
```json
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4",
"slug": "us-east-4"
"slug": "us-east-4",
"description": ""
},
"vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \
},
"role": null,
"is_pool": false,
"mark_utilized": false,
"description": "",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2020-08-04",
"last_updated": "2020-08-04T20:08:39.007125Z"
"created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2025-04-29T15:44:47.597092Z",
"children": 0,
"_depth": 0
}
```
@@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4",
"slug": "us-east-4"
"slug": "us-east-4",
"description": ""
},
"vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \
},
"role": null,
"is_pool": false,
"mark_utilized": false,
"description": "",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2020-08-04",
"last_updated": "2020-08-04T20:14:55.709430Z"
"created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2025-04-29T15:49:40.689109Z",
"children": 0,
"_depth": 0
}
```
@@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
## Uploading Files
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
For example, we can upload an image attachment using the `curl` command shown below. Note that the `@` signifies a local file on disk to be uploaded.
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-F "object_type=dcim.site" \
-F "object_id=2" \
-F "name=attachment1.png" \
-F "image=@local_file.png" \
http://netbox/api/extras/image-attachments/
```
## Authentication
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
@@ -653,6 +710,7 @@ Note that we are _not_ passing an existing REST API token with this request. If
{
"id": 6,
"url": "https://netbox/api/users/tokens/6/",
"display_url": "https://netbox/api/users/tokens/6/",
"display": "**********************************3c9cb9",
"user": {
"id": 2,

View File

@@ -1,5 +1,72 @@
# NetBox v4.2
## v4.2.9 (2025-04-30)
### Enhancements
* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list
* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms
* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list
* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history
* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules
* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations
* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view
* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address
* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu
### Bug Fixes
* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length
* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields
* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons
* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile
* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types
* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site
* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes
* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value
* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script
* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false
* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception
* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned
* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces
* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items
* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template
* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details
* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views
* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use
---
## v4.2.8 (2025-04-22)
### Enhancements
* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script
* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view
* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN
* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists
* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form
* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view
* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config
* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug
* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms
### Bug Fixes
* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model
* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API
* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views
* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields
* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces
* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members
* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices`
* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader
* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value
* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern
* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms
---
## v4.2.7 (2025-04-10)
### Enhancements
@@ -72,7 +139,6 @@
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces

View File

@@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
@@ -28,6 +28,8 @@ from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks
from utilities.views import register_model_view
@@ -123,12 +125,18 @@ class LoginView(View):
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
return response
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
username = form['username'].value()
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
return render(request, self.template_name, {
'form': form,
@@ -139,11 +147,11 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
if redirect_url and safe_for_redirect(redirect_url):
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
else:
if redirect_url:
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
redirect_url = reverse('home')
return HttpResponseRedirect(redirect_url)
@@ -218,7 +226,12 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)

View File

@@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
type = columns.ColoredLabelColumn(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_a = columns.TemplateColumn(

View File

@@ -36,7 +36,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
'related_models': self.get_related_models(
request,
instance,
omit=(),
extra=(
(
VirtualCircuit.objects.restrict(request.user, 'view').filter(
provider_network__provider=instance
),
'provider_id',
),
),
),
}
@@ -52,7 +64,7 @@ class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_import', detail=False)
@register_model_view(Provider, 'bulk_import', path='import', detail=False)
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderImportForm
@@ -118,7 +130,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_import', detail=False)
@register_model_view(ProviderAccount, 'bulk_import', path='import', detail=False)
class ProviderAccountBulkImportView(generic.BulkImportView):
queryset = ProviderAccount.objects.all()
model_form = forms.ProviderAccountImportForm
@@ -170,11 +182,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
instance,
omit=(CircuitTermination,),
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
'provider_network_id',
),
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
'provider_network_id',
),
),
),
}
@@ -192,7 +209,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_import', detail=False)
@register_model_view(ProviderNetwork, 'bulk_import', path='import', detail=False)
class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all()
model_form = forms.ProviderNetworkImportForm
@@ -249,7 +266,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_import', detail=False)
@register_model_view(CircuitType, 'bulk_import', path='import', detail=False)
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeImportForm
@@ -305,7 +322,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
@register_model_view(Circuit, 'bulk_import', detail=False)
@register_model_view(Circuit, 'bulk_import', path='import', detail=False)
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
@@ -450,7 +467,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'bulk_import', detail=False)
@register_model_view(CircuitTermination, 'bulk_import', path='import', detail=False)
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
@@ -511,7 +528,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_import', detail=False)
@register_model_view(CircuitGroup, 'bulk_import', path='import', detail=False)
class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm
@@ -561,7 +578,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all()
@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False)
@register_model_view(CircuitGroupAssignment, 'bulk_import', path='import', detail=False)
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm
@@ -618,7 +635,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
@register_model_view(VirtualCircuitType, 'bulk_import', path='import', detail=False)
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm

View File

@@ -0,0 +1,17 @@
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_job_object_type_optional'),
]
operations = [
migrations.AlterField(
model_name='job',
name='data',
field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
]

View File

@@ -5,6 +5,7 @@ import django_rq
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.urls import reverse
@@ -90,8 +91,9 @@ class Job(models.Model):
)
data = models.JSONField(
verbose_name=_('data'),
encoder=DjangoJSONEncoder,
null=True,
blank=True
blank=True,
)
error = models.TextField(
verbose_name=_('error'),

View File

@@ -47,6 +47,7 @@ class Plugin:
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
icon_url: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
@@ -193,6 +194,7 @@ def get_catalog_plugins():
# Populate plugin data
plugins[data['config_name']] = Plugin(
id=data['id'],
icon_url=data['icon'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],

View File

@@ -2,7 +2,7 @@ import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@@ -146,8 +146,10 @@ def handle_deleted_object(sender, instance, **kwargs):
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
# Similarly, for many-to-one relationships, we set the value on the related object to None
# and save it to trigger a change record on that object.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
@@ -157,7 +159,11 @@ def handle_deleted_object(sender, instance, **kwargs):
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.field.null is True:
setattr(obj, related_field_name, None)
obj.save()
# Enqueue the object for event processing
queue = events_queue.get()

View File

@@ -9,6 +9,12 @@ __all__ = (
)
PLUGIN_NAME_TEMPLATE = """
<img class="plugin-icon" src="{{ record.icon_url }}">
<a href="{% url 'core:plugin' record.config_name %}">{{ record.title_long }}</a>
"""
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
@@ -39,8 +45,8 @@ class PluginVersionTable(BaseTable):
class CatalogPluginTable(BaseTable):
title_long = tables.Column(
linkify=('core:plugin', [tables.A('config_name')]),
title_long = columns.TemplateColumn(
template_code=PLUGIN_NAME_TEMPLATE,
verbose_name=_('Name')
)
author = tables.Column(

View File

@@ -102,7 +102,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_import', detail=False)
@register_model_view(DataSource, 'bulk_import', path='import', detail=False)
class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm

View File

@@ -1057,6 +1057,13 @@ class DeviceFilterSet(
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
queryset=Rack.objects.all(),
@@ -1330,10 +1337,75 @@ class ModuleFilterSet(NetBoxModelFilterSet):
lookup_expr='in',
label=_('Module bay (ID)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location',
queryset=Location.objects.all(),
label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
queryset=Rack.objects.all(),
label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label=_('Rack (name)'),
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label=_('Device (ID)'),
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label=_('Device (name)'),
)
status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices,
null_value=None
@@ -1682,6 +1754,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
class CommonInterfaceFilterSet(django_filters.FilterSet):
mode = django_filters.MultipleChoiceFilter(
choices=InterfaceModeChoices,
label=_('802.1Q Mode')
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')

View File

@@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
class InventoryItemBulkCreateForm(
form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
form_from_model(InventoryItem, ['status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
DeviceBulkAddComponentForm
):
model = InventoryItem
field_order = (
'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'name', 'label', 'status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
)

View File

@@ -41,7 +41,6 @@ class InterfaceCommonForm(forms.Form):
def clean(self):
super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
if 'tagged_vlans' in self.fields.keys():
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
@@ -61,6 +60,12 @@ class InterfaceCommonForm(forms.Form):
"or they must be global"
).format(vlans=', '.join(invalid_vlans))
})
# Validate mode change
if self.instance.pk and (self.instance.mode != self.cleaned_data['mode']):
if 'untagged_vlan' not in self.cleaned_data and self.instance.untagged_vlan is not None:
self.instance.untagged_vlan = None
if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
self.instance.tagged_vlans.clear()
class ModuleCommonForm(forms.Form):

View File

@@ -6,7 +6,7 @@ from dcim.constants import *
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
from ipam.models import ASN, VRF
from ipam.models import ASN, VRF, VLANTranslationPolicy
from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -940,8 +940,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
},
label=_('Location')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
}
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -1332,6 +1380,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1403,6 +1452,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False,
label=_('PoE type')
)
mode = forms.MultipleChoiceField(
choices=InterfaceModeChoices,
required=False,
label=_('802.1Q mode')
)
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
queryset=VLANTranslationPolicy.objects.all(),
required=False,
label=_('VLAN Translation Policy')
)
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,

View File

@@ -899,7 +899,7 @@ class ComponentTemplateForm(forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all().all(),
queryset=DeviceType.objects.all(),
required=False,
context={
'parent': 'manufacturer',
@@ -914,6 +914,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
}
)
fieldsets = (
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'description'
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -930,10 +940,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta:
model = ConsolePortTemplate
fields = [
@@ -942,10 +948,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta:
model = ConsoleServerPortTemplate
fields = [
@@ -956,7 +958,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
),
)
@@ -978,7 +984,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
)
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
),
)
class Meta:
@@ -1001,7 +1013,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
@@ -1028,8 +1044,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
),
)
@@ -1043,7 +1062,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'positions', 'description',
),
)
class Meta:
@@ -1055,7 +1080,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'position', 'description',
),
)
class Meta:

View File

@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
from . import model_forms
@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
def clean(self):
super().clean()
# Validate that all replication fields generate an equal number of values
# Validate that all replication fields generate an equal number of values (or a single value)
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
return
pattern_count = len(patterns)
for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
if self.cleaned_data[field_name]:
if value_count == 1:
# If the field resolves to a single value (because no pattern was used), multiply it by the number
# of expected values. This allows us to reuse the same label when creating multiple components.
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
elif value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
#
@@ -114,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'rear_port', 'description',
),
)
class Meta(model_forms.FrontPortTemplateForm.Meta):
@@ -404,6 +414,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
queryset=Device.objects.all(),
required=False,
query_params={
'virtual_chassis_id': 'null',
'site_id': '$site',
'rack_id': '$rack',
}

View File

@@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm):
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'name', 'label', 'position', 'description',
'device_type', 'module_type', 'name', 'label', 'position', 'description',
]

View File

@@ -3,7 +3,6 @@ import itertools
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _
@@ -778,9 +777,28 @@ class CablePath(models.Model):
Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive.
"""
cable_ct = ObjectType.objects.get_for_model(Cable).pk
# Pre-cache cable lengths by ID
cable_ids = self.get_cable_ids()
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
total_length = cables.aggregate(total=Sum('_abs_length'))['total']
cables = {
cable['pk']: cable['_abs_length']
for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
}
# Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
# length of the path.
total_length = 0
for node_set in self.path:
hop_length = 0
for node in node_set:
ct, pk = decompile_path_node(node)
if ct != cable_ct:
break # Not a cable
if pk in cables and cables[pk] > hop_length:
hop_length = cables[pk]
total_length += hop_length
is_definitive = len(cables) == len(cable_ids)
return total_length, is_definitive

View File

@@ -725,3 +725,8 @@ class RackReservation(PrimaryModel):
@property
def unit_list(self):
return array_to_string(self.units)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.rack
return objectchange

View File

@@ -225,8 +225,7 @@ class CableTraceSVG:
"""
nodes_height = 0
nodes = []
# Sort them by name to make renders more readable
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
for i, term in enumerate(terminations):
node = Node(
position=(offset_x + i * width, self.cursor),
width=width,

View File

@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
INTERFACE_FHRPGROUPS = """
{% for assignment in value.all %}
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
{% endfor %}
"""

View File

@@ -2561,6 +2561,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
@@ -2617,15 +2619,23 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip4': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip6': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
@@ -2719,6 +2729,29 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
@@ -2726,11 +2759,65 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
devices = (
create_test_device('Test Device 1'),
create_test_device('Test Device 2'),
create_test_device('Test Device 3'),
device_types = (
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[1], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[2], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(
name='Test Device 1',
device_type=device_types[0],
role=roles[0],
site=sites[0],
location=locations[0],
rack=racks[0],
status='active',
),
Device(
name='Test Device 2',
device_type=device_types[1],
role=roles[1],
site=sites[1],
location=locations[1],
rack=racks[1],
status='planned',
),
Device(
name='Test Device 3',
device_type=device_types[2],
role=roles[2],
site=sites[2],
location=locations[2],
rack=racks[2],
status='offline',
),
)
Device.objects.bulk_create(devices)
module_types = (
ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
@@ -2878,6 +2965,41 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'asset_tag': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all()
@@ -4151,7 +4273,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
@@ -6450,15 +6572,23 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip4': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip6': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -1205,6 +1205,13 @@ front-ports:
- name: Front Port 3
type: 8p8c
rear_port: Rear Port 3
module-bays:
- name: Module Bay 1
position: 1
- name: Module Bay 2
position: 2
- name: Module Bay 3
position: 3
"""
# Create the manufacturer
@@ -1222,6 +1229,7 @@ front-ports:
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
form_data = {
@@ -1276,6 +1284,11 @@ front-ports:
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
self.assertEqual(module_type.modulebaytemplates.count(), 3)
mb1 = ModuleBayTemplate.objects.first()
self.assertEqual(mb1.name, 'Module Bay 1')
self.assertEqual(mb1.position, '1')
def test_export_objects(self):
url = reverse('dcim:moduletype_list')
self.add_permissions('dcim.view_moduletype')

View File

@@ -13,7 +13,7 @@ from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
@@ -23,6 +23,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.request import safe_for_redirect
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
@@ -237,7 +238,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
regions,
omit=(Cluster, Prefix, WirelessLAN),
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
@@ -247,8 +248,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
).distinct(),
'region_id'
),
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Region),
scope_id__in=regions
).distinct(),
'region'
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
'region_id'
),
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
@@ -269,7 +281,7 @@ class RegionDeleteView(generic.ObjectDeleteView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_import', detail=False)
@register_model_view(Region, 'bulk_import', path='import', detail=False)
class RegionBulkImportView(generic.BulkImportView):
queryset = Region.objects.all()
model_form = forms.RegionImportForm
@@ -336,10 +348,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
groups,
omit=(Cluster, Prefix, WirelessLAN),
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(
ASN.objects.restrict(request.user, 'view').filter(
sites__group__in=groups
).distinct(),
'site_group_id'),
(
VirtualMachine.objects.restrict(request.user, 'view').filter(
site__group__in=groups),
'site_group_id'
),
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(SiteGroup),
scope_id__in=groups
).distinct(),
'site_group'
),
(
Circuit.objects.restrict(request.user, 'view').filter(
terminations___site_group=instance
@@ -348,6 +379,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
),
(
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
@@ -377,7 +412,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_import', detail=False)
@register_model_view(SiteGroup, 'bulk_import', path='import', detail=False)
class SiteGroupBulkImportView(generic.BulkImportView):
queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupImportForm
@@ -455,6 +490,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
),
),
}
@@ -472,7 +508,7 @@ class SiteDeleteView(generic.ObjectDeleteView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_import', detail=False)
@register_model_view(Site, 'bulk_import', path='import', detail=False)
class SiteBulkImportView(generic.BulkImportView):
queryset = Site.objects.all()
model_form = forms.SiteImportForm
@@ -539,7 +575,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
locations,
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(
@@ -549,6 +585,10 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
'location_id'
),
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
@@ -571,7 +611,7 @@ class LocationDeleteView(generic.ObjectDeleteView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_import', detail=False)
@register_model_view(Location, 'bulk_import', path='import', detail=False)
class LocationBulkImportView(generic.BulkImportView):
queryset = Location.objects.all()
model_form = forms.LocationImportForm
@@ -645,7 +685,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_import', detail=False)
@register_model_view(RackRole, 'bulk_import', path='import', detail=False)
class RackRoleBulkImportView(generic.BulkImportView):
queryset = RackRole.objects.all()
model_form = forms.RackRoleImportForm
@@ -706,7 +746,7 @@ class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all()
@register_model_view(RackType, 'bulk_import', detail=False)
@register_model_view(RackType, 'bulk_import', path='import', detail=False)
class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm
@@ -814,7 +854,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
])
return {
'related_models': self.get_related_models(request, instance, [CableTermination]),
'related_models': self.get_related_models(
request,
instance,
omit=(CableTermination,),
extra=(
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Rack),
scope_id=instance.pk
), 'rack'),
),
),
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
@@ -874,7 +925,7 @@ class RackDeleteView(generic.ObjectDeleteView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_import', detail=False)
@register_model_view(Rack, 'bulk_import', path='import', detail=False)
class RackBulkImportView(generic.BulkImportView):
queryset = Rack.objects.all()
model_form = forms.RackImportForm
@@ -936,7 +987,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView):
queryset = RackReservation.objects.all()
@register_model_view(RackReservation, 'bulk_import', detail=False)
@register_model_view(RackReservation, 'bulk_import', path='import', detail=False)
class RackReservationImportView(generic.BulkImportView):
queryset = RackReservation.objects.all()
model_form = forms.RackReservationImportForm
@@ -1007,7 +1058,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_import', detail=False)
@register_model_view(Manufacturer, 'bulk_import', path='import', detail=False)
class ManufacturerBulkImportView(generic.BulkImportView):
queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerImportForm
@@ -1233,7 +1284,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
)
@register_model_view(DeviceType, 'bulk_import', detail=False)
@register_model_view(DeviceType, 'bulk_import', path='import', detail=False)
class DeviceTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_devicetype',
@@ -1447,7 +1498,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
)
@register_model_view(ModuleType, 'bulk_import', detail=False)
@register_model_view(ModuleType, 'bulk_import', path='import', detail=False)
class ModuleTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_moduletype',
@@ -1458,6 +1509,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
]
queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm
@@ -1469,6 +1521,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm,
'module-bays': forms.ModuleBayTemplateImportForm,
}
def prep_related_object_data(self, parent, data):
@@ -1943,7 +1996,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_import', detail=False)
@register_model_view(DeviceRole, 'bulk_import', path='import', detail=False)
class DeviceRoleBulkImportView(generic.BulkImportView):
queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleImportForm
@@ -2007,7 +2060,7 @@ class PlatformDeleteView(generic.ObjectDeleteView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_import', detail=False)
@register_model_view(Platform, 'bulk_import', path='import', detail=False)
class PlatformBulkImportView(generic.BulkImportView):
queryset = Platform.objects.all()
model_form = forms.PlatformImportForm
@@ -2290,7 +2343,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
@register_model_view(Device, 'bulk_import', detail=False)
@register_model_view(Device, 'bulk_import', path='import', detail=False)
class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
@@ -2368,7 +2421,7 @@ class ModuleDeleteView(generic.ObjectDeleteView):
queryset = Module.objects.all()
@register_model_view(Module, 'bulk_import', detail=False)
@register_model_view(Module, 'bulk_import', path='import', detail=False)
class ModuleBulkImportView(generic.BulkImportView):
queryset = Module.objects.all()
model_form = forms.ModuleImportForm
@@ -2429,7 +2482,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView):
queryset = ConsolePort.objects.all()
@register_model_view(ConsolePort, 'bulk_import', detail=False)
@register_model_view(ConsolePort, 'bulk_import', path='import', detail=False)
class ConsolePortBulkImportView(generic.BulkImportView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortImportForm
@@ -2504,7 +2557,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
queryset = ConsoleServerPort.objects.all()
@register_model_view(ConsoleServerPort, 'bulk_import', detail=False)
@register_model_view(ConsoleServerPort, 'bulk_import', path='import', detail=False)
class ConsoleServerPortBulkImportView(generic.BulkImportView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortImportForm
@@ -2579,7 +2632,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView):
queryset = PowerPort.objects.all()
@register_model_view(PowerPort, 'bulk_import', detail=False)
@register_model_view(PowerPort, 'bulk_import', path='import', detail=False)
class PowerPortBulkImportView(generic.BulkImportView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortImportForm
@@ -2654,7 +2707,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView):
queryset = PowerOutlet.objects.all()
@register_model_view(PowerOutlet, 'bulk_import', detail=False)
@register_model_view(PowerOutlet, 'bulk_import', path='import', detail=False)
class PowerOutletBulkImportView(generic.BulkImportView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletImportForm
@@ -2786,7 +2839,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView):
queryset = Interface.objects.all()
@register_model_view(Interface, 'bulk_import', detail=False)
@register_model_view(Interface, 'bulk_import', path='import', detail=False)
class InterfaceBulkImportView(generic.BulkImportView):
queryset = Interface.objects.all()
model_form = forms.InterfaceImportForm
@@ -2872,7 +2925,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView):
queryset = FrontPort.objects.all()
@register_model_view(FrontPort, 'bulk_import', detail=False)
@register_model_view(FrontPort, 'bulk_import', path='import', detail=False)
class FrontPortBulkImportView(generic.BulkImportView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortImportForm
@@ -2947,7 +3000,7 @@ class RearPortDeleteView(generic.ObjectDeleteView):
queryset = RearPort.objects.all()
@register_model_view(RearPort, 'bulk_import', detail=False)
@register_model_view(RearPort, 'bulk_import', path='import', detail=False)
class RearPortBulkImportView(generic.BulkImportView):
queryset = RearPort.objects.all()
model_form = forms.RearPortImportForm
@@ -3022,7 +3075,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView):
queryset = ModuleBay.objects.all()
@register_model_view(ModuleBay, 'bulk_import', detail=False)
@register_model_view(ModuleBay, 'bulk_import', path='import', detail=False)
class ModuleBayBulkImportView(generic.BulkImportView):
queryset = ModuleBay.objects.all()
model_form = forms.ModuleBayImportForm
@@ -3169,7 +3222,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
})
@register_model_view(DeviceBay, 'bulk_import', detail=False)
@register_model_view(DeviceBay, 'bulk_import', path='import', detail=False)
class DeviceBayBulkImportView(generic.BulkImportView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayImportForm
@@ -3235,7 +3288,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView):
queryset = InventoryItem.objects.all()
@register_model_view(InventoryItem, 'bulk_import', detail=False)
@register_model_view(InventoryItem, 'bulk_import', path='import', detail=False)
class InventoryItemBulkImportView(generic.BulkImportView):
queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemImportForm
@@ -3316,7 +3369,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_import', detail=False)
@register_model_view(InventoryItemRole, 'bulk_import', path='import', detail=False)
class InventoryItemRoleBulkImportView(generic.BulkImportView):
queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleImportForm
@@ -3512,7 +3565,7 @@ class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all()
@register_model_view(Cable, 'bulk_import', detail=False)
@register_model_view(Cable, 'bulk_import', path='import', detail=False)
class CableBulkImportView(generic.BulkImportView):
queryset = Cable.objects.all()
model_form = forms.CableImportForm
@@ -3741,7 +3794,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
)
))
if '_addanother' in request.POST:
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
return redirect(self.get_return_url(request, device))
@@ -3813,7 +3866,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
})
@register_model_view(VirtualChassis, 'bulk_import', detail=False)
@register_model_view(VirtualChassis, 'bulk_import', path='import', detail=False)
class VirtualChassisBulkImportView(generic.BulkImportView):
queryset = VirtualChassis.objects.all()
model_form = forms.VirtualChassisImportForm
@@ -3870,7 +3923,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_import', detail=False)
@register_model_view(PowerPanel, 'bulk_import', path='import', detail=False)
class PowerPanelBulkImportView(generic.BulkImportView):
queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelImportForm
@@ -3927,7 +3980,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_import', detail=False)
@register_model_view(PowerFeed, 'bulk_import', path='import', detail=False)
class PowerFeedBulkImportView(generic.BulkImportView):
queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedImportForm
@@ -3999,7 +4052,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_import', detail=False)
@register_model_view(VirtualDeviceContext, 'bulk_import', path='import', detail=False)
class VirtualDeviceContextBulkImportView(generic.BulkImportView):
queryset = VirtualDeviceContext.objects.all()
model_form = forms.VirtualDeviceContextImportForm
@@ -4049,7 +4102,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
queryset = MACAddress.objects.all()
@register_model_view(MACAddress, 'bulk_import', detail=False)
@register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all()
model_form = forms.MACAddressImportForm

View File

@@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
)
def clean_extra_choices(self):

View File

@@ -123,7 +123,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
ordered = [
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
]
ordered.extend(script_objects.items())
ordered.extend(script_objects.values())
return ordered
@property

View File

@@ -528,14 +528,9 @@ class BaseScript:
"""
Return data from a YAML file
"""
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile, Loader=Loader)
data = yaml.load(datafile, Loader=yaml.SafeLoader)
return data

View File

@@ -5,6 +5,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from extras.models import *
from core.tables import JobTable
from core.models import Job
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.events import get_event_text
from netbox.tables import BaseTable, NetBoxTable, columns
@@ -26,6 +28,7 @@ __all__ = (
'SavedFilterTable',
'ReportResultsTable',
'ScriptResultsTable',
'ScriptJobTable',
'SubscriptionTable',
'TaggedItemTable',
'TagTable',
@@ -638,6 +641,23 @@ class ScriptResultsTable(BaseTable):
return format_html("<a href='{}'>{}</a>", value, value)
class ScriptJobTable(JobTable):
id = tables.TemplateColumn(
template_code="""<a href="{% url 'extras:script_result' job_pk=record.pk %}">{{ record.id }}</a>""",
verbose_name=_('ID'),
)
class Meta(NetBoxTable.Meta):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
)
class ReportResultsTable(BaseTable):
index = tables.Column(
verbose_name=_('Line')

View File

@@ -15,7 +15,6 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
@@ -36,7 +35,7 @@ from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
from .models import *
from .tables import ReportResultsTable, ScriptResultsTable
from .tables import ReportResultsTable, ScriptResultsTable, ScriptJobTable
#
@@ -83,7 +82,7 @@ class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'bulk_import', detail=False)
@register_model_view(CustomField, 'bulk_import', path='import', detail=False)
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm
@@ -152,7 +151,7 @@ class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_import', detail=False)
@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False)
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
@@ -202,7 +201,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_import', detail=False)
@register_model_view(CustomLink, 'bulk_import', path='import', detail=False)
class CustomLinkBulkImportView(generic.BulkImportView):
queryset = CustomLink.objects.all()
model_form = forms.CustomLinkImportForm
@@ -257,7 +256,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_import', detail=False)
@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False)
class ExportTemplateBulkImportView(generic.BulkImportView):
queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateImportForm
@@ -334,7 +333,7 @@ class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_import', detail=False)
@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm
@@ -415,7 +414,7 @@ class NotificationGroupDeleteView(generic.ObjectDeleteView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_import', detail=False)
@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False)
class NotificationGroupBulkImportView(generic.BulkImportView):
queryset = NotificationGroup.objects.all()
model_form = forms.NotificationGroupImportForm
@@ -561,7 +560,7 @@ class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_import', detail=False)
@register_model_view(Webhook, 'bulk_import', path='import', detail=False)
class WebhookBulkImportView(generic.BulkImportView):
queryset = Webhook.objects.all()
model_form = forms.WebhookImportForm
@@ -611,7 +610,7 @@ class EventRuleDeleteView(generic.ObjectDeleteView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_import', detail=False)
@register_model_view(EventRule, 'bulk_import', path='import', detail=False)
class EventRuleBulkImportView(generic.BulkImportView):
queryset = EventRule.objects.all()
model_form = forms.EventRuleImportForm
@@ -684,7 +683,7 @@ class TagDeleteView(generic.ObjectDeleteView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_import', detail=False)
@register_model_view(Tag, 'bulk_import', path='import', detail=False)
class TagBulkImportView(generic.BulkImportView):
queryset = Tag.objects.all()
model_form = forms.TagImportForm
@@ -860,7 +859,7 @@ class ConfigTemplateDeleteView(generic.ObjectDeleteView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_import', detail=False)
@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False)
class ConfigTemplateBulkImportView(generic.BulkImportView):
queryset = ConfigTemplate.objects.all()
model_form = forms.ConfigTemplateImportForm
@@ -1039,7 +1038,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
return reverse(viewname, kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'bulk_import', detail=False)
@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)
class JournalEntryBulkImportView(generic.BulkImportView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryImportForm
@@ -1351,7 +1350,7 @@ class ScriptJobsView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
jobs_table = JobTable(
jobs_table = ScriptJobTable(
data=script.jobs.all(),
orderable=False,
user=request.user

View File

@@ -351,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
to_field_name='rd',
label=_('VRF (RD)'),
)
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group',
queryset=VLANGroup.objects.all(),
to_field_name="id",
label=_('VLAN Group (ID)'),
)
vlan_group = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group__slug',
queryset=VLANGroup.objects.all(),
to_field_name="slug",
label=_('VLAN Group (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('VLAN (ID)'),
@@ -1207,8 +1219,20 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip4 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4__address',
queryset=IPAddress.objects.all(),
to_field_name='address',
label=_('Primary IPv4 (address)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
primary_ip6 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6__address',
queryset=IPAddress.objects.all(),
to_field_name='address',
label=_('Primary IPv6 (address)'),
)

View File

@@ -176,7 +176,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
),
FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -260,6 +260,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
vlan_group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label=_('VLAN Group'),
)
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,

View File

@@ -538,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.populate_custom_field_defaults()
ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions
@@ -841,6 +840,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
self.fields[field].widget.is_required = False
def clean(self):
super().clean()

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -6,7 +7,7 @@ from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext_lazy as _
from dcim.models import Interface
from dcim.models import Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
@@ -279,12 +280,20 @@ class VLAN(PrimaryModel):
super().clean()
# Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site:
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(Site):
if self.site != self.group.scope:
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(SiteGroup):
if self.site not in self.group.scope.sites.all():
raise ValidationError(
_(
"The assigned site {site} is not a member of the assigned group {group} (scope: {scope})."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:

View File

@@ -645,9 +645,16 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
vrfs[1].export_targets.add(route_targets[1])
vrfs[2].export_targets.add(route_targets[2])
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
)
for vlan_group in vlan_groups:
vlan_group.save()
vlans = (
VLAN(vid=1, name='VLAN 1'),
VLAN(vid=2, name='VLAN 2'),
VLAN(vid=1, name='VLAN 1', group=vlan_groups[0]),
VLAN(vid=2, name='VLAN 2', group=vlan_groups[1]),
VLAN(vid=3, name='VLAN 3'),
)
VLAN.objects.bulk_create(vlans)
@@ -850,6 +857,13 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vlan_group(self):
vlan_groups = VLANGroup.objects.all()[:2]
params = {'vlan_group_id': [vlan_groups[0].pk, vlan_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vlan(self):
vlans = VLAN.objects.all()[:2]
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}

View File

@@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges
from dcim.models import Site, SiteGroup
from ipam.choices import *
from ipam.models import *
@@ -645,3 +647,54 @@ class TestVLAN(TestCase):
)
with self.assertRaises(ValidationError):
vlan.full_clean()
def test_vlan_group_site_validation(self):
sitegroup = SiteGroup.objects.create(
name='Site Group 1',
slug='site-group-1',
)
sites = Site.objects.bulk_create((
Site(
name='Site 1',
slug='site-1',
),
Site(
name='Site 2',
slug='site-2',
),
))
sitegroup.sites.add(sites[0])
vlangroups = VLANGroup.objects.bulk_create((
VLANGroup(
name='VLAN Group 1',
slug='vlan-group-1',
scope=sitegroup,
scope_type=ContentType.objects.get_for_model(SiteGroup),
),
VLANGroup(
name='VLAN Group 2',
slug='vlan-group-2',
scope=sites[0],
scope_type=ContentType.objects.get_for_model(Site),
),
VLANGroup(
name='VLAN Group 2',
slug='vlan-group-2',
scope=sites[1],
scope_type=ContentType.objects.get_for_model(Site),
),
))
vlan = VLAN(
name='VLAN 1',
vid=1,
group=vlangroups[0],
site=sites[0],
)
# VLAN Group 1 and 2 should be valid
vlan.full_clean()
vlan.group = vlangroups[1]
vlan.full_clean()
vlan.group = vlangroups[2]
with self.assertRaises(ValidationError):
vlan.full_clean()

View File

@@ -70,7 +70,7 @@ class VRFDeleteView(generic.ObjectDeleteView):
queryset = VRF.objects.all()
@register_model_view(VRF, 'bulk_import', detail=False)
@register_model_view(VRF, 'bulk_import', path='import', detail=False)
class VRFBulkImportView(generic.BulkImportView):
queryset = VRF.objects.all()
model_form = forms.VRFImportForm
@@ -120,7 +120,7 @@ class RouteTargetDeleteView(generic.ObjectDeleteView):
queryset = RouteTarget.objects.all()
@register_model_view(RouteTarget, 'bulk_import', detail=False)
@register_model_view(RouteTarget, 'bulk_import', path='import', detail=False)
class RouteTargetBulkImportView(generic.BulkImportView):
queryset = RouteTarget.objects.all()
model_form = forms.RouteTargetImportForm
@@ -177,7 +177,7 @@ class RIRDeleteView(generic.ObjectDeleteView):
queryset = RIR.objects.all()
@register_model_view(RIR, 'bulk_import', detail=False)
@register_model_view(RIR, 'bulk_import', path='import', detail=False)
class RIRBulkImportView(generic.BulkImportView):
queryset = RIR.objects.all()
model_form = forms.RIRImportForm
@@ -252,7 +252,7 @@ class ASNRangeDeleteView(generic.ObjectDeleteView):
queryset = ASNRange.objects.all()
@register_model_view(ASNRange, 'bulk_import', detail=False)
@register_model_view(ASNRange, 'bulk_import', path='import', detail=False)
class ASNRangeBulkImportView(generic.BulkImportView):
queryset = ASNRange.objects.all()
model_form = forms.ASNRangeImportForm
@@ -317,7 +317,7 @@ class ASNDeleteView(generic.ObjectDeleteView):
queryset = ASN.objects.all()
@register_model_view(ASN, 'bulk_import', detail=False)
@register_model_view(ASN, 'bulk_import', path='import', detail=False)
class ASNBulkImportView(generic.BulkImportView):
queryset = ASN.objects.all()
model_form = forms.ASNImportForm
@@ -409,7 +409,7 @@ class AggregateDeleteView(generic.ObjectDeleteView):
queryset = Aggregate.objects.all()
@register_model_view(Aggregate, 'bulk_import', detail=False)
@register_model_view(Aggregate, 'bulk_import', path='import', detail=False)
class AggregateBulkImportView(generic.BulkImportView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateImportForm
@@ -477,7 +477,7 @@ class RoleDeleteView(generic.ObjectDeleteView):
queryset = Role.objects.all()
@register_model_view(Role, 'bulk_import', detail=False)
@register_model_view(Role, 'bulk_import', path='import', detail=False)
class RoleBulkImportView(generic.BulkImportView):
queryset = Role.objects.all()
model_form = forms.RoleImportForm
@@ -663,7 +663,7 @@ class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all()
@register_model_view(Prefix, 'bulk_import', detail=False)
@register_model_view(Prefix, 'bulk_import', path='import', detail=False)
class PrefixBulkImportView(generic.BulkImportView):
queryset = Prefix.objects.all()
model_form = forms.PrefixImportForm
@@ -757,7 +757,7 @@ class IPRangeDeleteView(generic.ObjectDeleteView):
queryset = IPRange.objects.all()
@register_model_view(IPRange, 'bulk_import', detail=False)
@register_model_view(IPRange, 'bulk_import', path='import', detail=False)
class IPRangeBulkImportView(generic.BulkImportView):
queryset = IPRange.objects.all()
model_form = forms.IPRangeImportForm
@@ -926,7 +926,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView):
template_name = 'ipam/ipaddress_bulk_add.html'
@register_model_view(IPAddress, 'bulk_import', detail=False)
@register_model_view(IPAddress, 'bulk_import', path='import', detail=False)
class IPAddressBulkImportView(generic.BulkImportView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressImportForm
@@ -1004,7 +1004,7 @@ class VLANGroupDeleteView(generic.ObjectDeleteView):
queryset = VLANGroup.objects.all()
@register_model_view(VLANGroup, 'bulk_import', detail=False)
@register_model_view(VLANGroup, 'bulk_import', path='import', detail=False)
class VLANGroupBulkImportView(generic.BulkImportView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupImportForm
@@ -1091,7 +1091,7 @@ class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationPolicy.objects.all()
@register_model_view(VLANTranslationPolicy, 'bulk_import', detail=False)
@register_model_view(VLANTranslationPolicy, 'bulk_import', path='import', detail=False)
class VLANTranslationPolicyBulkImportView(generic.BulkImportView):
queryset = VLANTranslationPolicy.objects.all()
model_form = forms.VLANTranslationPolicyImportForm
@@ -1146,7 +1146,7 @@ class VLANTranslationRuleDeleteView(generic.ObjectDeleteView):
queryset = VLANTranslationRule.objects.all()
@register_model_view(VLANTranslationRule, 'bulk_import', detail=False)
@register_model_view(VLANTranslationRule, 'bulk_import', path='import', detail=False)
class VLANTranslationRuleBulkImportView(generic.BulkImportView):
queryset = VLANTranslationRule.objects.all()
model_form = forms.VLANTranslationRuleImportForm
@@ -1227,7 +1227,7 @@ class FHRPGroupDeleteView(generic.ObjectDeleteView):
queryset = FHRPGroup.objects.all()
@register_model_view(FHRPGroup, 'bulk_import', detail=False)
@register_model_view(FHRPGroup, 'bulk_import', path='import', detail=False)
class FHRPGroupBulkImportView(generic.BulkImportView):
queryset = FHRPGroup.objects.all()
model_form = forms.FHRPGroupImportForm
@@ -1353,7 +1353,7 @@ class VLANDeleteView(generic.ObjectDeleteView):
queryset = VLAN.objects.all()
@register_model_view(VLAN, 'bulk_import', detail=False)
@register_model_view(VLAN, 'bulk_import', path='import', detail=False)
class VLANBulkImportView(generic.BulkImportView):
queryset = VLAN.objects.all()
model_form = forms.VLANImportForm
@@ -1403,7 +1403,7 @@ class ServiceTemplateDeleteView(generic.ObjectDeleteView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, 'bulk_import', detail=False)
@register_model_view(ServiceTemplate, 'bulk_import', path='import', detail=False)
class ServiceTemplateBulkImportView(generic.BulkImportView):
queryset = ServiceTemplate.objects.all()
model_form = forms.ServiceTemplateImportForm
@@ -1458,7 +1458,7 @@ class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
@register_model_view(Service, 'bulk_import', detail=False)
@register_model_view(Service, 'bulk_import', path='import', detail=False)
class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all()
model_form = forms.ServiceImportForm

View File

@@ -43,7 +43,12 @@ class CoreMiddleware:
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id

View File

@@ -301,6 +301,14 @@ class CustomFieldsMixin(models.Model):
if cf.required and cf.name not in self.custom_field_data:
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
def save(self, *args, **kwargs):
# Populate default values if omitted
for cf in self.custom_fields.filter(default__isnull=False):
if cf.name not in self.custom_field_data:
self.custom_field_data[cf.name] = cf.default
super().save(*args, **kwargs)
class CustomLinksMixin(models.Model):
"""

View File

@@ -419,7 +419,7 @@ INSTALLED_APPS = [
'drf_spectacular',
'drf_spectacular_sidecar',
]
if not DEBUG:
if not DEBUG and 'collectstatic' not in sys.argv:
INSTALLED_APPS.remove('debug_toolbar')
# Middleware

View File

@@ -29,6 +29,7 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin
@@ -120,7 +121,10 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Strip the `export` param and redirect user to the filtered objects list
query_params = request.GET.copy()
query_params.pop('export')
return redirect(f'{request.path}?{query_params.urlencode()}')
redirect_url = f'{request.path}?{query_params.urlencode()}'
if safe_for_redirect(redirect_url):
return redirect(redirect_url)
return redirect(get_viewname(self.queryset.model, 'list'))
#
# Request handlers
@@ -284,7 +288,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
logger.info(msg)
messages.success(request, msg)
if '_addanother' in request.POST:
if '_addanother' in request.POST and safe_for_redirect(request.path):
return redirect(request.path)
return redirect(self.get_return_url(request))

View File

@@ -20,6 +20,7 @@ from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields
from utilities.request import safe_for_redirect
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin
@@ -315,6 +316,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
redirect_url += f"?{params.urlencode()}"
if not safe_for_redirect(redirect_url):
redirect_url = reverse('home')
return redirect(redirect_url)
@@ -581,7 +584,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
))
# Redirect user on success
if '_addanother' in request.POST:
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
else:
return redirect(self.get_return_url(request))

View File

@@ -9,8 +9,7 @@ const options = {
outdir: './dist',
bundle: true,
minify: true,
sourcemap: 'external',
sourcesContent: false,
sourcemap: 'linked',
logLevel: 'error',
};

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

@@ -1,6 +1,6 @@
{
"name": "netbox",
"version": "4.1.0",
"version": "4.2.8",
"main": "dist/netbox.js",
"license": "Apache-2.0",
"private": true,
@@ -24,13 +24,13 @@
"dependencies": {
"@mdi/font": "7.4.47",
"@tabler/core": "1.0.0-beta21",
"bootstrap": "5.3.3",
"bootstrap": "5.3.5",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "11.5.0",
"htmx.org": "1.9.12",
"query-string": "9.1.1",
"sass": "1.86.0",
"sass": "1.87.0",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -5,11 +5,13 @@ interface PluginConfig {
export function getPlugins(element: HTMLSelectElement): object {
const plugins: PluginConfig = {};
// Enable "clear all" button
plugins.clear_button = {
html: (data: Dict) =>
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
};
// Enable "clear all" button for non-required fields
if (!element.required) {
plugins.clear_button = {
html: (data: Dict) =>
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
};
}
// Enable individual "remove" buttons for items on multi-select fields
if (element.hasAttribute('multiple')) {

View File

@@ -63,3 +63,27 @@ span.color-label {
.sso-icon {
height: 24px;
}
.btn-white {
@extend .btn-light;
}
.btn-black {
@extend .btn-dark;
}
.btn-grey, .btn-gray {
@extend .btn-secondary;
}
img.plugin-icon {
max-width: 1.4285em;
height: auto;
}
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {
filter: grayscale(100%) invert(100%) brightness(80%);
}
}

View File

@@ -3,6 +3,12 @@ html {
scroll-behavior: auto !important;
}
// Remove horizontal padding from highlighted text
mark {
padding-left: 0;
padding-right: 0;
}
// Prevent dropdown menus from being clipped inside responsive tables
.table-responsive {
.dropdown, .btn-group, .btn-group-vertical {

View File

@@ -1,11 +1,6 @@
// Navbar and light theme styling
.navbar-vertical.navbar-expand-lg {
// Adds spacing to the bottom of the side navigation to avoid hidden nav items
@include media-breakpoint-up(lg) {
padding-bottom: 2rem;
}
// Adjust hover color & style for menu items
.navbar-collapse {
.nav-link-icon {

View File

@@ -1066,6 +1066,11 @@ bootstrap@5.3.3:
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.3.tgz#de35e1a765c897ac940021900fcbb831602bac38"
integrity sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==
bootstrap@5.3.5:
version "5.3.5"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.5.tgz#be42cfe0d580e97ee1abb7d38ce94f5c393c9bb6"
integrity sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2673,10 +2678,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.86.0:
version "1.86.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114"
integrity sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==
sass@1.87.0:
version "1.87.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e"
integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@@ -1,3 +1,3 @@
version: "4.2.7"
version: "4.2.9"
edition: "Community"
published: "2025-04-10"
published: "2025-04-30"

View File

@@ -21,7 +21,7 @@ Blocks:
{# Sidebar #}
<aside class="navbar navbar-vertical navbar-expand-lg d-print-none">
{% if 'commercial' in settings.RELEASE.features %}
{% if settings.RELEASE.features.commercial %}
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">
{% endif %}
@@ -51,8 +51,19 @@ Blocks:
{# Navigation menu #}
<div class="collapse navbar-collapse" id="sidebar-menu">
{% nav %}
</div>
{# Release info #}
<div class="text-muted text-center fs-5 my-3">
{{ settings.RELEASE.name }}
{% if not settings.RELEASE.features.commercial %}
<div>
<a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> |
<a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a>
</div>
{% endif %}
</div>
</div>
</div>
</aside>
@@ -210,7 +221,6 @@ Blocks:
<ul class="list-inline list-inline-dots fs-5 mb-0" id="footer-stamp" hx-swap-oob="true">
<li class="list-inline-item">{% now 'Y-m-d H:i:s T' %}</li>
<li class="list-inline-item">{{ settings.HOSTNAME }}</li>
<li class="list-inline-item">{{ settings.RELEASE.name }}</li>
</ul>
{# /Footer text #}

View File

@@ -10,7 +10,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit" %}</h2>
<table class="table table-hover attr-table">
@@ -89,7 +89,7 @@
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/image_attachments.html' %}

View File

@@ -20,7 +20,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group" %}</h2>
<table class="table table-hover attr-table">
@@ -46,7 +46,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}

View File

@@ -14,7 +14,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2>
<table class="table table-hover attr-table">
@@ -39,7 +39,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -10,7 +10,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
{% if object %}
@@ -37,7 +37,7 @@
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}

View File

@@ -14,7 +14,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Type" %}</h2>
<table class="table table-hover attr-table">
@@ -41,7 +41,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}

View File

@@ -15,7 +15,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider" %}</h2>
<table class="table table-hover attr-table">
@@ -39,7 +39,7 @@
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider Account" %}</h2>
<table class="table table-hover attr-table">
@@ -33,7 +33,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider Network" %}</h2>
<table class="table table-hover attr-table">
@@ -37,7 +37,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}

View File

@@ -15,7 +15,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual circuit" %}</h2>
<table class="table table-hover attr-table">
@@ -61,7 +61,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
<div class="card">

View File

@@ -18,7 +18,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
<table class="table table-hover attr-table">
@@ -48,7 +48,7 @@
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table">

View File

@@ -14,7 +14,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2>
<table class="table table-hover attr-table">
@@ -41,7 +41,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}

View File

@@ -26,7 +26,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Data Source" %}</h2>
<table class="table table-hover attr-table">
@@ -79,7 +79,7 @@
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Backend" %}</h2>
{% with backend=object.backend_class %}

View File

@@ -30,7 +30,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Job" %}</h2>
<table class="table table-hover attr-table">
@@ -61,7 +61,7 @@
</table>
</div>
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Scheduling" %}</h2>
<table class="table table-hover attr-table">

View File

@@ -24,7 +24,7 @@
{% block content %}
<div class="row">
<div class="col col-md-5">
<div class="col col-12 col-md-5">
<div class="card">
<h2 class="card-header">{% trans "Change" %}</h2>
<table class="table table-hover attr-table">
@@ -73,7 +73,7 @@
</table>
</div>
</div>
<div class="col col-md-7">
<div class="col col-12 col-md-7">
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Difference" %}
@@ -106,7 +106,7 @@
</div>
</div>
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Pre-Change Data" %}</h2>
<div class="card-body">
@@ -126,7 +126,7 @@
</div>
</div>
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Post-Change Data" %}</h2>
<div class="card-body">
@@ -146,10 +146,10 @@
</div>
</div>
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% plugin_right_page object %}
</div>
</div>

View File

@@ -7,7 +7,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Cable" %}</h2>
<table class="table table-hover attr-table">
@@ -63,7 +63,7 @@
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Termination" %} A</h2>
{% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}

View File

@@ -1,5 +1,5 @@
{% extends 'generic/object_edit.html' %}
{% block form %}
{% include 'dcim/htmx/cable_edit.html' %}
{% include 'dcim/htmx/cable_edit.html' %}
{% endblock %}

View File

@@ -63,7 +63,7 @@
<td>
{% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
{{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
{{ total_length|meters_to_feet|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Feet" %}
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Console Port" %}</h2>
<table class="table table-hover attr-table">
@@ -50,7 +50,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Console Server Port" %}</h2>
<table class="table table-hover attr-table">
@@ -50,7 +50,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
<div class="card-body">

View File

@@ -3,7 +3,9 @@
{% load i18n %}
{% block form %}
{% render_errors form %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="field-group my-5">
<div class="row">

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Device Bay" %}</h2>
<table class="table table-hover attr-table">
@@ -38,7 +38,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Installed Device" %}</h2>
{% if object.installed_device %}

View File

@@ -18,7 +18,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Device Role" %}</h2>
<table class="table table-hover attr-table">
@@ -49,7 +49,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}

View File

@@ -6,7 +6,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Chassis" %}</h2>
<table class="table table-hover attr-table">
@@ -96,7 +96,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Front Port" %}</h2>
<table class="table table-hover attr-table">
@@ -64,7 +64,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
{% if object.mark_connected %}

View File

@@ -3,6 +3,9 @@
{% load form_helpers %}
{% load i18n %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{# A side termination #}
<div class="field-group mb-5">

View File

@@ -20,10 +20,15 @@
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
<td>
{% for term in terminations %}
{{term.device|linkify}}
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
{{ term|linkify }}
{% if not forloop.last %}<br/>{% endif %}
{{ term.device|linkify }}
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
{{ term|linkify }}
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
</tr>
@@ -41,7 +46,13 @@
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{{ term|linkify }}
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
</tr>
@@ -55,7 +66,13 @@
<th scope="row">{% trans "Circuit" %}</th>
<td>
{% for term in terminations %}
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
{{ term.circuit|linkify }} ({{ term }})
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
</tr>

View File

@@ -22,7 +22,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table">
@@ -122,7 +122,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
<div class="card">
<h2 class="card-header">{% trans "Addressing" %}</h2>

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Inventory Item" %}</h2>
<table class="table table-hover attr-table">
@@ -70,7 +70,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="col col-12 col-md-6">
{% plugin_right_page object %}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More