Compare commits

..

60 Commits

Author SHA1 Message Date
Jason Novinger
290e4afaa0 Release v4.3.6 (#20081)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
2025-08-12 12:15:08 -05:00
github-actions
ca95050b7d Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-12 05:02:37 +00:00
m-hau
34e4ccb212 Fixes #20056: Add rf_role to generate_schema.json (#20071)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-11 10:40:01 -07:00
Jonathan Ramstedt
fcb49f9881 Closes #19728: add c18 power port type (#20064)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-11 08:41:14 -05:00
Jason Novinger
7e40f40248 Closes #20060: Update v4.3.5 release docs to note impact of fixing #18900 2025-08-11 08:31:03 -04:00
github-actions
8e08524fed Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-08-09 05:03:34 +00:00
Jason Novinger
8bb47dad0f Fixes #20023: Add GiST index on Prefix.prefix for net contains ops (#20059)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Resolves performance issue where prefix deletion with 2000+ children
took 5-10 minutes due to sequential scans in hierarchy depth/children
calculations. Adding PostgreSQL GiST index with inet_ops enables efficient
network containment operators (>>, <<, <<=) in annotate_hierarchy() queries.

Performance impact:
- 30-60x speedup: 5-10 minutes → 10 seconds for large prefix deletions
- Real-world validation: 4s migration time on 1.24M prefix dataset
- Storage cost: 47MB index (11% of table storage, 38 bytes per prefix)

Works in conjunction with existing B-tree indexes on vrf_id for optimal
query performance. Benefits all network containment operations including
hierarchy navigation, aggregate views, and available IP/prefix calculations.
2025-08-08 14:14:55 -05:00
Daniel Sheppard
5d7c8318aa Fixes: #19996 - Correct dynamic query parameters for IP Address field in Add/Edit Service form (#20040)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
* Fixes: #19996 - Correct dynamic query parameters for IP Address field in Add/Edit Service form

* Remove debug and do some cleanup
2025-08-08 09:52:03 -04:00
bctiemann
2d495d4f32 Merge pull request #20026 from netbox-community/19998-fixes-missing-changelog-cleared-tags
Fixes: #19998 - Add changelog entry when clearing M2M fields
2025-08-08 06:17:30 -04:00
github-actions
cea83f31b8 Update source translation strings
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-08 05:09:12 +00:00
Daniel Sheppard
6c0dc8b630 Correct mistake made on determination of whether it is a tag or not
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-08-07 21:12:25 -05:00
bctiemann
1c86f81298 Merge pull request #20034 from netbox-community/20033-bookmark-bulk-deletion
Fixes #20033: Fix exception when bulk deleting bookmarks
2025-08-07 20:29:10 -04:00
Daniel Sheppard
630d7aa4c2 Clarify additional branch functionality
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-08-07 08:27:13 -05:00
Daniel Sheppard
043275df19 Clarify label 2025-08-07 08:24:54 -05:00
Jonathan Ramstedt
122f612750 Fixes #19379: allow standalone id in vlan-ids range list (#20024)
Some checks failed
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
* Fixes #19379: allow standalone id in vlan-ids range list

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-08-07 08:56:07 -04:00
github-actions
65b36fd594 Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-07 05:08:34 +00:00
Jeremy Stretch
e828ca5cb4 Fixes #20030: Fix height of object list action buttons & others (#20036)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-06 13:49:52 -07:00
Jeremy Stretch
fce10c73b7 Closes #17222: Improve visibility of notifications icon (#20035) 2025-08-06 14:28:01 -05:00
bctiemann
0cf76bc5c7 Merge pull request #20021 from netbox-community/19999-script-list-widget-misformatted
Fixes #19999: Script list dashboard widget now displays correctly
2025-08-06 15:08:56 -04:00
Jeremy Stretch
11f228cae9 Fixes #20033: Fix exception when bulk deleting bookmarks 2025-08-06 10:29:17 -04:00
Daniel Sheppard
a86cd9dfc6 Clarify comment 2025-08-05 15:49:01 -05:00
Daniel Sheppard
15541c6440 Fixes: #19998 - Add changelog entry when clearing M2M fields 2025-08-05 15:28:41 -05:00
github-actions
6ce3012f93 Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-08-05 05:08:54 +00:00
bctiemann
fec6cf705f Merge pull request #20015 from mraerino/graphql-contains-prefix
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Implement `contains` filter for IPAM prefixes and IP ranges
2025-08-04 18:16:19 -04:00
Jason Novinger
9c6d0d1ddc Fixes #19999: Script list widget now displays correctly
- Extract script list content into reusable partial template
- Add object-list CSS class for proper embedded table styling
- Hide module headers and management actions in widget context
- Use compact buttons with icon-only labels for widgets
- Add test coverage for embedded parameter handling

The embedded version now renders cleanly in dashboard widgets while
preserving full functionality in the main script list page.
2025-08-04 16:52:11 -05:00
Jason Novinger
47359d9284 Fixes #20017: ensures full ChangeLog change is highlighted (#20018) 2025-08-04 14:27:39 -07:00
Jeremy Stretch
669df62cde Closes #18873: Add a request timeout parameter to the RSS feed dashboard widget (#20004) 2025-08-04 14:23:33 -07:00
Marcus Weiner
9df0bdcfaf Fixes #19622: Allow loading graphql query from URL (#20014)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-08-04 09:28:59 -05:00
Jad Seifeddine
d222913716 Fixes: #19917 - Fix MAC address pagination duplicates by adding 'pk' to model ordering (#19961)
* Fix MAC address pagination duplicates by adding 'pk' to model ordering

Add 'pk' to MACAddress model ordering to ensure deterministic results
when multiple MAC addresses have the same value. This prevents the same
MAC address from appearing on multiple pages during pagination.

The issue occurred because Django's default ordering by 'mac_address'
alone is non-deterministic when multiple records share the same MAC
address value, causing inconsistent pagination results when the same
MAC address is assigned to multiple interfaces on a device.

Added regression test that verifies MAC addresses with identical values
are properly ordered by their primary key, ensuring consistent pagination
behavior across the application.

Fixes netbox-community#19917

* Remove test

* Resolve migration conflict

---------

Co-authored-by: Jad Seifeddine <jseifeddine@macquarietelecom.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-08-04 10:15:05 -04:00
Jason Novinger
2c09973e01 Fixes #20009: Fix DOM-based XSS vulnerability in search export functionality
Replace direct string concatenation with URLSearchParams to properly
encode user input in export link URLs, preventing injection of malicious
parameters or scripts through the search functionality.

Resolves CodeQL Alert #63 (js/xss-through-dom)
2025-08-04 09:12:49 -04:00
Marcus Weiner
4506c809d8 Fix formatting 2025-08-03 15:32:55 +02:00
Marcus Weiner
5d194214aa Implement contains filter for IPAM IP ranges 2025-08-03 15:32:14 +02:00
Marcus Weiner
0827198cad Implement contains filter for IPAM prefixes 2025-08-03 15:19:58 +02:00
github-actions
bb83187505 Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-08-02 05:05:04 +00:00
Jeremy Stretch
aa9ee0e5c6 Closes #19977: Denormalize device relationships on component models (#19984)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Closes #19977: Denormalize site, location, and rack for device components

* Set blank=True on denormalized ForeignKeys

* Populate denormalized field in test data

* Ignore private fields when constructing test GraphQL requests
2025-08-01 15:40:15 -05:00
Jeremy Stretch
35b9d80819 Closes #19968: Use multiple selection lists for the assignment of object types when editing a permission (#19991)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
* Closes #19968: Use  multiple selection lists for the assignment of object types when editing a permission

* Remove errant logging statements

* Defer compilation of choices for object_types

* Fix test data
2025-08-01 14:06:23 -05:00
Jeremy Stretch
d4b30a64ba Fixes #20001: is_api_request() should not evaluate a request's content type 2025-08-01 14:31:50 -04:00
Jeremy Stretch
de53fd2bd1 Configure CodeQL to ignore compiled JS resources (#20000)
* Configure CodeQL to ignore compiled JS resources

* Enable CodeQL for feature branch
2025-08-01 12:39:25 -05:00
Jonathan Ramstedt
c7b68664f9 Closes #18843: use color name in cable export (#19983) 2025-08-01 09:51:00 -07:00
Jeremy Stretch
a20715f229 Fixes #19321: Reduce redundant database queries during bulk creation of devices (#19993)
* Fixes #19321: Reduce redundant database queries during bulk creation of devices

* Add test for test_get_prefetchable_fields
2025-08-01 09:23:58 -05:00
Jason Novinger
1b8767f1e3 Remove housekeeping item from v4.3.5 rlease notes 2025-07-30 08:25:40 -04:00
github-actions
5acef5038f Update source translation strings 2025-07-30 05:08:57 +00:00
Jason Novinger
6ca3908715 Release v4.3.5 (#19975)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Release v4.3.5

* Fix missing strawberry-graphql==0.278.0 specifier
2025-07-29 15:28:49 -05:00
Jason Novinger
c736ce3179 Fixes #18900: raise QuerySetNotOrdered exception when trying to paginate unordered API querysets (#19943)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
* Fixes #18900: introduce/raise QuerySetNotOrdered exception

Defines a new exception, `QuerySetNotOrdered`, and raises it in
`OptionalLimitOffsetPagination.paginate_queryset` in the right
conditions:
- the iterable to be paginated is a QuerySet isinstance
- the `queryset.ordered` flag is not truthy

* Don't try to reapply ordering if ordering is already present

* Add ordering for failing tagged-objects list API endpoint

I chose to implement this here for TaggedItemViewSet, rather than on the
model, because any meaningful ordering is going to be done on the
related Tag instance and I didn't want to introduce potential, not well
understood side-effects by applying a model-wide ordering via a related
model field.

* Add default Token ordering behavior

* Adds basic tests for raising QuerySetNotOrdered

* Note why ordering is not applied in TaggedItem.Meta
2025-07-29 11:49:36 -05:00
Martin Rødvand
111fefdf9c Fix #19910: Add conditional to hide internet dependent links in an isolated deployment (#19951)
* Add conditional to hide internet dependent links in an isolated deployment

* Formatting

* Adjust conditional

* Formatting
2025-07-29 10:41:32 -05:00
Jeremy Stretch
063d1fef7a Closes #18797: Support path import for certain Jinja environment parameters (#19962)
* Closes #18797: Support path import for certain Jinja environment parameters

* Document dotted path support for Jinja env params
2025-07-29 09:09:25 -05:00
Jathn
6ba6ff3fee Fixes #19764: docs/administration replicating netbox - wrong table name 2025-07-29 08:57:49 -04:00
github-actions
7bb7307892 Update source translation strings 2025-07-29 05:10:14 +00:00
Jeremy Stretch
c2d3363930 Closes #18399: Refactor logic for marking data source syncing as queued (#19960)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-28 09:04:38 -07:00
Jeremy Stretch
6e30c11017 Fixes #19956: Prevent duplicate deletion records from cascading deletions 2025-07-28 09:49:08 -04:00
github-actions
b01c75cf3a Update source translation strings 2025-07-25 05:07:26 +00:00
Jonathan Ramstedt
ffa9a52667 Closes #18936: add color name support for cable bulk import (#19949)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-24 09:54:49 -07:00
bctiemann
47320f9958 Merge pull request #19912 from miaow2/19903-regexp
Closes #19903: Add `regex` and `iregex` filter lookup expressions and corresponding tests
2025-07-24 12:32:19 -04:00
github-actions
d08a1bd07d Update source translation strings 2025-07-24 05:05:44 +00:00
Martin Hauser
14c4aeca54 Closes #19840 - Enable Site Filtering for Devices in Cable Bulk Import (#19923)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
* feat(dcim): Add site fields to Cable bulk import form

Introduces `side_a_site` and `side_b_site` fields for the Cable bulk
import form. Limits device choices on both sides to the selected site
for improved input validation and consistency.

* feat(dcim): Enhance test data setup with multiple sites

Refactors tests to create multiple sites and assign devices accordingly.
Updates CSV data to include `side_a_site` and `side_b_site` fields for
scenarios involving multiple sites. This improves test coverage and
alignment with real-world use cases.

* docs(dcim): Update comments explaining indent for CSV import

Improved the inline comments to clarify the rationale behind allowing
devices with duplicate names on different sites during CSV bulk import.
2025-07-23 15:50:05 -05:00
Jason Novinger
26bec1275f Fixes #19934: add description field to Tenant bulk edit form (#19937) 2025-07-23 13:41:00 -07:00
Jason Novinger
fa2d7f6516 Fixes #19916: restore Rack device representation behavior
The select list of 'Images and Label', 'Images Only', and 'Label Only'
was broken during recent work while implementing #19823.

This fixes the issue by placing the `rack_elevation` class attribute on
the <div> element that contains the SVG after being loaded by HTMX. In
addition, we needed to slightly modify the selectors in the frontend
code that looked for the elements within the SVG to hide and/or show.
Previously, it was looking inside of a contentDocument embedded in an
<object> element. The simplified version just looks inside of the
SVG containing div.
2025-07-23 08:45:40 -04:00
Marco Spizzuoco
d571cb4867 Closes #19902: add clip path to avoid overflow of device name, truncate text to improve centering (#19913)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-22 09:44:14 -07:00
bluikko
2129355c30 Closes #19926: Remove RHEL firewalld note
Closes: #19926
2025-07-22 08:04:53 -04:00
Artem Kotik
c40bfb1445 Add regex and iregex filter lookup expressions and corresponding tests 2025-07-18 16:56:54 +02:00
241 changed files with 16515 additions and 15027 deletions

View File

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

3
.github/codeql/codeql-config.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
paths-ignore:
# Ignore compiled JS
- netbox/project-static/dist

42
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: "CodeQL"
on:
push:
branches: [ "main", "feature" ]
pull_request:
branches: [ "main", "feature" ]
schedule:
- cron: '38 16 * * 4'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -8,7 +8,9 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar
# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
# see https://github.com/netbox-community/netbox/issues/19974
django-debug-toolbar==5.2.0
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -145,8 +147,7 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
# See #19771
strawberry-graphql-django==0.60.0
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@@ -95,6 +95,7 @@
"iec-60320-c8",
"iec-60320-c14",
"iec-60320-c16",
"iec-60320-c18",
"iec-60320-c20",
"iec-60320-c22",
"iec-60309-p-n-e-4h",
@@ -209,6 +210,7 @@
"iec-60320-c7",
"iec-60320-c13",
"iec-60320-c15",
"iec-60320-c17",
"iec-60320-c19",
"iec-60320-c21",
"iec-60309-p-n-e-4h",
@@ -474,6 +476,13 @@
"passive-48v-2pair",
"passive-48v-4pair"
]
},
"rf_role": {
"type": "string",
"enum": [
"ap",
"station"
]
}
}
},

View File

@@ -0,0 +1,17 @@
[Unit]
Description=NetBox Housekeeping Service
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netbox
Group=netbox
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
[Install]
WantedBy=multi-user.target

9
contrib/netbox-housekeeping.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
# This shell script invokes NetBox's housekeeping management command, which
# intended to be run nightly. This script can be copied into your system's
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
# within the cron configuration file.
#
# If NetBox has been installed into a nonstandard location, update the paths
# below.
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping

View File

@@ -0,0 +1,13 @@
[Unit]
Description=NetBox Housekeeping Timer
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,49 @@
# Housekeeping
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
## Scheduling
### Using Cron
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
### Using Systemd
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
```bash
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
```
Then, reload the systemd configuration and enable the timer to start automatically at boot:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now netbox-housekeeping.timer
```
Check the status of your timer by running:
```bash
sudo systemctl list-timers --all
```
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.

View File

@@ -18,10 +18,10 @@ pg_dump --username netbox --password --host localhost netbox > netbox.sql
!!! note
You may need to change the username, host, and/or database in the command above to match your installation.
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
```
### Load an Exported Database

View File

@@ -72,16 +72,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
## HOSTNAME
!!! info "This parameter was introduced in NetBox v4.4."
Default: System hostname
The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`.
---
## HTTP_PROXIES
Default: `None`
@@ -169,7 +159,6 @@ LOGGING = {
* `netbox.auth.*` - Authentication events
* `netbox.api.views.*` - Views which handle business logic for the REST API
* `netbox.event_rules` - Event rules
* `netbox.jobs.*` - Background jobs
* `netbox.reports.*` - Report execution (`module.name`)
* `netbox.scripts.*` - Custom script execution (`module.name`)
* `netbox.views.*` - Views which handle business logic for the web UI

View File

@@ -264,6 +264,18 @@ cd /opt/netbox/netbox
python3 manage.py createsuperuser
```
## Schedule the Housekeeping Task
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
## Test the Application
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
@@ -290,13 +302,6 @@ Quit the server with CONTROL-C.
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
!!! note
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
```no-highlight
firewall-cmd --zone=public --add-port=8000/tcp
```
!!! danger "Not for production use"
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**

View File

@@ -183,3 +183,13 @@ Finally, restart the gunicorn and RQ services:
```no-highlight
sudo systemctl restart netbox netbox-rq
```
## 6. Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
### Name
A human-friendly name for the platform. Must be unique per manufacturer.
A unique human-friendly name.
### Slug
A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
A unique URL-friendly identifier. (This value can be used for filtering.)
### Manufacturer

View File

@@ -24,6 +24,14 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type
!!! info "This field was introduced in NetBox v4.3."

View File

@@ -26,6 +26,14 @@ Jinja2 template code for rendering the exported data.
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.

View File

@@ -25,9 +25,6 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
### VLAN Group or Site
!!! warning "Site assignment is deprecated"
The assignment of individual VLANs directly to a site has been deprecated. This ability will be removed in a future NetBox release. Users are strongly encouraged to utilize VLAN groups, which have the added benefit of supporting the assignment of a VLAN to multiple sites.
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
### Q-in-Q Role

View File

@@ -39,27 +39,6 @@ You can schedule the background job from within your code (e.g. from a model's `
This is the human-friendly names of your background job. If omitted, the class name will be used.
### Logging
!!! info "This feature was introduced in NetBox v4.4."
A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
```python
def run(self, *args, **kwargs):
obj = MyModel.objects.get(pk=kwargs.get('pk'))
self.logger.info("Retrieved object {obj}")
```
Four of the standard Python logging levels are supported:
* `debug()`
* `info()`
* `warning()`
* `error()`
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
### Scheduled Jobs
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.

View File

@@ -64,7 +64,6 @@ Generic view classes (documented below) facilitate common operations, such as cr
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkRenameView` | Rename multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
@@ -172,10 +171,6 @@ Below are the class definitions for NetBox's multi-object views. These views han
options:
members: false
::: netbox.views.generic.BulkRenameView
options:
members: false
::: netbox.views.generic.BulkDeleteView
options:
members:

View File

@@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
String based (char) fields (Name, Address, etc) support these lookup expressions:
| Filter | Description |
|---------|----------------------------------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) |
| Filter | Description |
|----------|----------------------------------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) |
| `regex` | Regexp matching |
| `iregex` | Regexp matching (case-insensitive) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command
* Delete change log records which have surpassed the configured retention period (if configured)
* Check for new NetBox releases (if enabled)
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details.
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details.
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))

View File

@@ -1,5 +1,53 @@
# NetBox v4.3
## v4.3.6 (2025-08-12)
### Enhancements
* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
### Bug Fixes
* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
## v4.3.5 (2025-07-29)
### Enhancements
* [#18797](https://github.com/netbox-community/netbox/issues/18797) - Added jinja2.StrictUndefined option for config template rendering to catch undefined variables
* [#18936](https://github.com/netbox-community/netbox/issues/18936) - Cable imports now accept color names (e.g. "red", "blue") in addition to hex color codes
* [#19840](https://github.com/netbox-community/netbox/issues/19840) - Cable imports now support specifying site information for better organization
* [#19902](https://github.com/netbox-community/netbox/issues/19902) - Device names in rack elevation SVG exports are automatically truncated to prevent overflow beyond rack unit boundaries
* [#19903](https://github.com/netbox-community/netbox/issues/19903) - String field filters now support `regex` and `iregex` lookups for advanced pattern matching
* [#19910](https://github.com/netbox-community/netbox/issues/19910) - Internet-dependent links are no longer visible when running in air-gapped environments
### Bug Fixes
* [#18900](https://github.com/netbox-community/netbox/issues/18900) - REST API paginator now raises proper exceptions when attempting to paginate unordered querysets
* [#19916](https://github.com/netbox-community/netbox/issues/19916) - Rack elevation image/label dropdown functionality restored
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
!!! note "Plugin Developer Advisory"
The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
## v4.3.4 (2025-07-15)
### Enhancements

View File

@@ -158,6 +158,7 @@ nav:
- Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- Data Model:

View File

@@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.query import count_related
@@ -80,11 +79,6 @@ class ProviderBulkEditView(generic.BulkEditView):
form = forms.ProviderBulkEditForm
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(
@@ -147,11 +141,6 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
form = forms.ProviderAccountBulkEditForm
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderAccount.objects.annotate(
@@ -223,11 +212,6 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
form = forms.ProviderNetworkBulkEditForm
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderNetwork.objects.all()
@@ -287,11 +271,6 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
form = forms.CircuitTypeBulkEditForm
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
@@ -358,12 +337,6 @@ class CircuitBulkEditView(generic.BulkEditView):
form = forms.CircuitBulkEditForm
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
@@ -459,7 +432,6 @@ class CircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitTermination)
@@ -554,11 +526,6 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
form = forms.CircuitGroupBulkEditForm
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all()
@@ -576,7 +543,6 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitGroupAssignment)
@@ -669,11 +635,6 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate(
@@ -736,12 +697,6 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitBulkEditForm
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -759,7 +714,6 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VirtualCircuitTermination)

View File

@@ -1,5 +1,4 @@
from .serializers_.change_logging import *
from .serializers_.data import *
from .serializers_.jobs import *
from .serializers_.object_types import *
from .serializers_.tasks import *

View File

@@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@@ -1,47 +0,0 @@
import inspect
from django.urls import NoReverseMatch, reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer
from utilities.views import get_viewname
__all__ = (
'ObjectTypeSerializer',
)
class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
is_plugin_model = serializers.BooleanField(read_only=True)
rest_api_endpoint = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
class Meta:
model = ObjectType
fields = [
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
'is_plugin_model', 'rest_api_endpoint', 'description',
]
@extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()):
return
if viewname := get_viewname(model, action='list', rest_api=True):
try:
return reverse(viewname)
except NoReverseMatch:
return
@extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj):
if not (model := obj.model_class()):
return
return inspect.getdoc(model)

View File

@@ -10,7 +10,6 @@ router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)
router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')

View File

@@ -1,30 +1,28 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_redis_connection
from django_rq.settings import QUEUES_LIST
from django_rq.utils import get_statistics
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet
from rq.job import Job as RQ_Job
from rq.worker import Worker
from core import filtersets
from core.choices import DataSourceStatusChoices
from core.jobs import SyncDataSourceJob
from core.models import *
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
from django_rq.queues import get_redis_connection
from django_rq.utils import get_statistics
from django_rq.settings import QUEUES_LIST
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser
from rq.job import Job as RQ_Job
from rq.worker import Worker
from . import serializers
@@ -51,10 +49,8 @@ class DataSourceViewSet(NetBoxModelViewSet):
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
# Enqueue the sync job & update the DataSource's status
# Enqueue the sync job
SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
@@ -86,16 +82,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
filterset_class = filtersets.ObjectChangeFilterSet
class ObjectTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ObjectType.objects.order_by('app_label', 'model')
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
class BaseRQViewSet(viewsets.ViewSet):
"""
Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().

View File

@@ -4,31 +4,23 @@ from django.utils.translation import gettext_lazy as _
from rq.job import JobStatus
__all__ = (
'JOB_LOG_ENTRY_LEVELS',
'RQ_TASK_STATUSES',
)
@dataclass
class Badge:
class Status:
label: str
color: str
RQ_TASK_STATUSES = {
JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
JobStatus.FINISHED: Badge(_('Finished'), 'green'),
JobStatus.FAILED: Badge(_('Failed'), 'red'),
JobStatus.STARTED: Badge(_('Started'), 'blue'),
JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'),
}
JOB_LOG_ENTRY_LEVELS = {
'debug': Badge(_('Debug'), 'gray'),
'info': Badge(_('Info'), 'blue'),
'warning': Badge(_('Warning'), 'orange'),
'error': Badge(_('Error'), 'red'),
JobStatus.QUEUED: Status(_('Queued'), 'cyan'),
JobStatus.FINISHED: Status(_('Finished'), 'green'),
JobStatus.FAILED: Status(_('Failed'), 'red'),
JobStatus.STARTED: Status(_('Started'), 'blue'),
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'),
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'),
JobStatus.STOPPED: Status(_('Stopped'), 'orange'),
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'),
}

View File

@@ -1,21 +0,0 @@
import logging
from dataclasses import dataclass, field
from datetime import datetime
from django.utils import timezone
__all__ = (
'JobLogEntry',
)
@dataclass
class JobLogEntry:
level: str
message: str
timestamp: datetime = field(default_factory=timezone.now)
@classmethod
def from_logrecord(cls, record: logging.LogRecord):
return cls(record.levelname.lower(), record.msg)

View File

@@ -1,8 +1,9 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from users.models import User
@@ -16,7 +17,6 @@ __all__ = (
'DataSourceFilterSet',
'JobFilterSet',
'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
)
@@ -134,25 +134,6 @@ class JobFilterSet(BaseFilterSet):
)
class ObjectTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -1,20 +1,13 @@
import logging
import sys
from datetime import timedelta
from importlib import import_module
import requests
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from packaging import version
import sys
from core.models import Job, ObjectChange
from netbox.config import Config
from django.conf import settings
from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend
from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices
from .exceptions import SyncError
from .models import DataSource
logger = logging.getLogger(__name__)
@@ -28,25 +21,32 @@ class SyncDataSourceJob(JobRunner):
class Meta:
name = 'Synchronization'
@classmethod
def enqueue(cls, *args, **kwargs):
job = super().enqueue(*args, **kwargs)
# Update the DataSource's synchronization status to queued
if datasource := job.object:
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
return job
def run(self, *args, **kwargs):
datasource = DataSource.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found DataSource ID {datasource.pk}")
try:
self.logger.info(f"Syncing data source {datasource}")
datasource.sync()
# Update the search cache for DataFiles belonging to this source
self.logger.debug("Updating search cache for data files")
search_backend.cache(datasource.datafiles.iterator())
except Exception as e:
self.logger.error(f"Error syncing data source: {e}")
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) is SyncError:
logging.error(e)
raise e
self.logger.info("Syncing completed successfully")
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class SystemHousekeepingJob(JobRunner):
@@ -61,23 +61,16 @@ class SystemHousekeepingJob(JobRunner):
if settings.DEBUG or 'test' in sys.argv:
return
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
self.send_census_report()
self.clear_expired_sessions()
self.prune_changelog()
self.delete_expired_jobs()
self.check_for_new_releases()
@staticmethod
def send_census_report():
"""
Send a census report (if enabled).
"""
logging.info("Reporting census data...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.CENSUS_REPORTING_ENABLED:
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
# Skip if census reporting is disabled
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED:
return
census_data = {
@@ -94,94 +87,3 @@ class SystemHousekeepingJob(JobRunner):
)
except requests.exceptions.RequestException:
pass
@staticmethod
def clear_expired_sessions():
"""
Clear any expired sessions from the database.
"""
logging.info("Clearing expired sessions...")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
logging.info("Sessions cleared.")
except NotImplementedError:
logging.warning(
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
@staticmethod
def prune_changelog():
"""
Delete any ObjectChange records older than the configured changelog retention time (if any).
"""
logging.info("Pruning old changelog entries...")
config = Config()
if not config.CHANGELOG_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def delete_expired_jobs():
"""
Delete any jobs older than the configured retention period (if any).
"""
logging.info("Deleting expired jobs...")
config = Config()
if not config.JOB_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = Job.objects.filter(created__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def check_for_new_releases():
"""
Check for new releases and cache the latest release.
"""
logging.info("Checking for new releases...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.RELEASE_CHECK_URL:
logging.info("RELEASE_CHECK_URL is not set; skipping")
return
# Fetch the latest releases
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
try:
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers={'Accept': 'application/vnd.github.v3+json'},
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
logging.error(f"Error fetching release: {exc}")
return
# Determine the most recent stable release
releases = []
for release in response.json():
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
logging.info(f"Latest release: {latest_release[0]}")
# Cache the most recent release
cache.set('latest_release', latest_release, None)

View File

@@ -1,28 +0,0 @@
import django.contrib.postgres.fields
import django.core.serializers.json
from django.db import migrations, models
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('core', '0015_remove_redundant_indexes'),
]
operations = [
migrations.AddField(
model_name='job',
name='log_entries',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.JSONField(
decoder=utilities.json.JobLogDecoder,
encoder=django.core.serializers.json.DjangoJSONEncoder
),
blank=True,
default=list,
size=None
),
),
]

View File

@@ -1,9 +1,7 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
from django.db.models import Q
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
__all__ = (
'ObjectType',
@@ -50,29 +48,3 @@ class ObjectType(ContentType):
class Meta:
proxy = True
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@@ -1,12 +1,9 @@
import logging
import uuid
from dataclasses import asdict
from functools import partial
import django_rq
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator
@@ -17,10 +14,8 @@ from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices
from core.dataclasses import JobLogEntry
from core.models import ObjectType
from core.signals import job_end, job_start
from utilities.json import JobLogDecoder
from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model
@@ -109,15 +104,6 @@ class Job(models.Model):
verbose_name=_('job ID'),
unique=True
)
log_entries = ArrayField(
verbose_name=_('log entries'),
base_field=models.JSONField(
encoder=DjangoJSONEncoder,
decoder=JobLogDecoder,
),
blank=True,
default=list,
)
objects = RestrictedQuerySet.as_manager()
@@ -130,7 +116,7 @@ class Job(models.Model):
verbose_name_plural = _('jobs')
def __str__(self):
return self.name
return str(self.job_id)
def get_absolute_url(self):
# TODO: Employ dynamic registration
@@ -218,13 +204,6 @@ class Job(models.Model):
# Send signal
job_end.send(self)
def log(self, record: logging.LogRecord):
"""
Record a LogRecord from Python's native logging in the job's log.
"""
entry = JobLogEntry.from_logrecord(record)
self.log_entries.append(asdict(entry))
@classmethod
def enqueue(
cls,

View File

@@ -1,18 +0,0 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkSync',
)
class BulkSync(ObjectAction):
"""
Synchronize multiple objects at once.
"""
name = 'bulk_sync'
label = _('Sync Data')
multi = True
permissions_required = {'sync'}
template_name = 'core/buttons/bulk_sync.html'

View File

@@ -1,16 +1,19 @@
import logging
from threading import local
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
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.core.signals import request_finished
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.events import *
from extras.events import enqueue_event
from extras.models import Tag
from extras.utils import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
@@ -42,6 +45,10 @@ clear_events = Signal()
# Change logging & event handling
#
# Used to track received signals per object
_signals_received = local()
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
@@ -66,6 +73,17 @@ def handle_changed_object(sender, instance, **kwargs):
# m2m_changed with objects added or removed
m2m_changed = True
event_type = OBJECT_UPDATED
elif kwargs.get('action') == 'post_clear':
# Handle clearing of an M2M field
if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
# Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
# prechange snapshot is empty)
m2m_changed = True
event_type = OBJECT_UPDATED
else:
# Other endpoints are unimpacted as they send post_add and post_remove
# This will impact changes that utilize clear() however so we may want to give consideration for this branch
return
else:
return
@@ -130,6 +148,16 @@ def handle_deleted_object(sender, instance, **kwargs):
if request is None:
return
# Check whether we've already processed a pre_delete signal for this object. (This can
# happen e.g. when both a parent object and its child are deleted simultaneously, due
# to cascading deletion.)
if not hasattr(_signals_received, 'pre_delete'):
_signals_received.pre_delete = set()
signature = (ContentType.objects.get_for_model(instance), instance.pk)
if signature in _signals_received.pre_delete:
return
_signals_received.pre_delete.add(signature)
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
@@ -179,6 +207,14 @@ def handle_deleted_object(sender, instance, **kwargs):
model_deletes.labels(instance._meta.model_name).inc()
@receiver(request_finished)
def clear_signal_history(sender, **kwargs):
"""
Clear out the signals history once the request is finished.
"""
_signals_received.pre_delete = set()
@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""

View File

@@ -1,11 +1,12 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from core.constants import RQ_TASK_STATUSES
from netbox.registry import registry
__all__ = (
'BackendTypeColumn',
'BadgeColumn',
'RQJobStatusColumn',
)
@@ -22,21 +23,14 @@ class BackendTypeColumn(tables.Column):
return value
class BadgeColumn(tables.Column):
class RQJobStatusColumn(tables.Column):
"""
Render a colored badge for a value.
Args:
badges: A dictionary mapping of values to core.constants.Badge instances.
Render a colored label for the status of an RQ job.
"""
def __init__(self, badges, *args, **kwargs):
super().__init__(*args, **kwargs)
self.badges = badges
def render(self, value):
badge = self.badges.get(value)
return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
status = RQ_TASK_STATUSES.get(value)
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>')
def value(self, value):
badge = self.badges.get(value)
return badge.label
status = RQ_TASK_STATUSES.get(value)
return status.label

View File

@@ -1,10 +1,8 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable, NetBoxTable, columns
from core.constants import JOB_LOG_ENTRY_LEVELS
from core.models import Job
from core.tables.columns import BadgeColumn
from netbox.tables import NetBoxTable, columns
from ..models import Job
class JobTable(NetBoxTable):
@@ -42,9 +40,6 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
actions = columns.ActionsColumn(
actions=('delete',)
)
@@ -58,24 +53,3 @@ class JobTable(NetBoxTable):
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
)
def render_log_entries(self, value):
return len(value)
class JobLogEntryTable(BaseTable):
timestamp = columns.DateTimeColumn(
timespec='milliseconds',
verbose_name=_('Time'),
)
level = BadgeColumn(
badges=JOB_LOG_ENTRY_LEVELS,
verbose_name=_('Level'),
)
message = tables.Column(
verbose_name=_('Message'),
)
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')

View File

@@ -2,8 +2,7 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import A
from core.constants import RQ_TASK_STATUSES
from core.tables.columns import BadgeColumn
from core.tables.columns import RQJobStatusColumn
from netbox.tables import BaseTable, columns
@@ -85,8 +84,7 @@ class BackgroundTaskTable(BaseTable):
ended_at = columns.DateTimeColumn(
verbose_name=_("Ended")
)
status = BadgeColumn(
badges=RQ_TASK_STATUSES,
status = RQJobStatusColumn(
verbose_name=_("Status"),
accessor='get_status'
)

View File

@@ -7,7 +7,6 @@ from django.utils import timezone
from rq.job import Job as RQ_Job, JobStatus
from rq.registry import FailedJobRegistry, StartedJobRegistry
from rest_framework import status
from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase
from utilities.testing.utils import disable_logging
@@ -102,22 +101,6 @@ class DataFileTest(
DataFile.objects.bulk_create(data_files)
class ObjectTypeTest(APITestCase):
def test_list_objects(self):
object_type_count = ObjectType.objects.count()
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], object_type_count)
def test_get_object(self):
object_type = ObjectType.objects.first()
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
class BackgroundTaskTestCase(TestCase):
user_permissions = ()

View File

@@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
def test_duplicate_deletions(self):
"""
Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for
the same object.
"""
role1 = DeviceRole(name='Role 1', slug='role-1')
role1.save()
role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1)
role2.save()
pk_list = [role1.pk, role2.pk]
# Delete both objects simultaneously
form_data = {
'pk': pk_list,
'confirm': True,
'_confirm': True,
}
request = {
'path': reverse('dcim:devicerole_bulk_delete'),
'data': post_data(form_data),
}
self.add_permissions('dcim.delete_devicerole')
self.assertHttpStatus(self.client.post(**request), 302)
# This should result in exactly one change record per object
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(DeviceRole),
changed_object_id__in=pk_list,
action=ObjectChangeActionChoices.ACTION_DELETE
)
self.assertEqual(objectchanges.count(), 2)
class ChangeLogAPITest(APITestCase):

View File

@@ -22,7 +22,6 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.registry import registry
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
@@ -32,13 +31,12 @@ from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
from .tables import CatalogPluginTable, PluginVersionTable
#
@@ -79,12 +77,8 @@ class DataSourceSyncView(BaseObjectView):
def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk)
# Enqueue the sync job & update the DataSource's status
# Enqueue the sync job
job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
messages.success(
request,
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
@@ -120,11 +114,6 @@ class DataSourceBulkEditView(generic.BulkEditView):
form = forms.DataSourceBulkEditForm
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate(
@@ -144,13 +133,14 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable
actions = (BulkDelete,)
actions = {
'bulk_delete': {'delete'},
}
@register_model_view(DataFile)
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
actions = (DeleteObject,)
@register_model_view(DataFile, 'delete')
@@ -175,32 +165,15 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
actions = (BulkExport, BulkDelete)
actions = {
'export': {'view'},
'bulk_delete': {'delete'},
}
@register_model_view(Job)
class JobView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
@register_model_view(Job, 'log')
class JobLogView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
template_name = 'core/job/log.html'
tab = ViewTab(
label=_('Log'),
badge=lambda obj: len(obj.log_entries),
weight=500,
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
table.configure(request)
return {
'table': table,
}
@register_model_view(Job, 'delete')
@@ -226,7 +199,9 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
@register_model_view(ObjectChange)
@@ -294,7 +269,6 @@ class ConfigRevisionListView(generic.ObjectListView):
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
actions = (AddObject, BulkExport)
@register_model_view(ConfigRevision)

View File

@@ -344,6 +344,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_C8 = 'iec-60320-c8'
TYPE_IEC_C14 = 'iec-60320-c14'
TYPE_IEC_C16 = 'iec-60320-c16'
TYPE_IEC_C18 = 'iec-60320-c18'
TYPE_IEC_C20 = 'iec-60320-c20'
TYPE_IEC_C22 = 'iec-60320-c22'
# IEC 60309
@@ -462,6 +463,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_C8, 'C8'),
(TYPE_IEC_C14, 'C14'),
(TYPE_IEC_C16, 'C16'),
(TYPE_IEC_C18, 'C18'),
(TYPE_IEC_C20, 'C20'),
(TYPE_IEC_C22, 'C22'),
)),
@@ -599,6 +601,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_C7 = 'iec-60320-c7'
TYPE_IEC_C13 = 'iec-60320-c13'
TYPE_IEC_C15 = 'iec-60320-c15'
TYPE_IEC_C17 = 'iec-60320-c17'
TYPE_IEC_C19 = 'iec-60320-c19'
TYPE_IEC_C21 = 'iec-60320-c21'
# IEC 60309
@@ -711,6 +714,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_C7, 'C7'),
(TYPE_IEC_C13, 'C13'),
(TYPE_IEC_C15, 'C15'),
(TYPE_IEC_C17, 'C17'),
(TYPE_IEC_C19, 'C19'),
(TYPE_IEC_C21, 'C21'),
)),

View File

@@ -1515,34 +1515,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
field_name='_site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location',
field_name='_location',
queryset=Location.objects.all(),
label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug',
field_name='_location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
field_name='_rack',
queryset=Rack.objects.all(),
label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
field_name='_rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label=_('Rack (name)'),

View File

@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_site = CSVModelChoiceField(
label=_('Side A site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device A (if any)'),
)
side_a_device = CSVModelChoiceField(
label=_('Side A device'),
queryset=Device.objects.all(),
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
)
# Termination B
side_b_site = CSVModelChoiceField(
label=_('Side B site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device B (if any)'),
)
side_b_device = CSVModelChoiceField(
label=_('Side B device'),
queryset=Device.objects.all(),
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Length unit')
)
color = forms.CharField(
label=_('Color'),
required=False,
max_length=16,
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
)
class Meta:
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
)
def _clean_side(self, side):
"""
Derive a Cable's A/B termination objects.
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
def _clean_color(self, color):
"""
Derive a colors hex code
:param color: color as hex or color name
"""
color_parsed = color.strip().lower()
for hex_code, label in ColorChoices.CHOICES:
if color.lower() == label.lower():
color_parsed = hex_code
if len(color_parsed) > 6:
raise forms.ValidationError(
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
)
return color_parsed
def clean_side_a_name(self):
return self._clean_side('a')
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
length_unit = self.cleaned_data.get('length_unit', None)
return length_unit if length_unit is not None else ''
def clean_color(self):
color = self.cleaned_data.get('color', None)
return self._clean_color(color) if color is not None else ''
#
# Virtual chassis
#
class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField(
label=_('Master'),

View File

@@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tx_power = forms.IntegerField(
required=False,
label=_('Transmit power (dBm)'),
min_value=-40,
min_value=0,
max_value=127
)
vrf_id = DynamicModelMultipleChoiceField(

View File

@@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
from dcim.choices import *
from netbox.choices import WeightUnitChoices
from wireless.choices import WirelessRoleChoices
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
OUTPUT_FILENAME = 'contrib/generated_schema.json'
@@ -23,6 +24,7 @@ CHOICES_MAP = {
'interface_type_choices': InterfaceTypeChoices,
'interface_poe_mode_choices': InterfacePoEModeChoices,
'interface_poe_type_choices': InterfacePoETypeChoices,
'interface_rf_role_choices': WirelessRoleChoices,
'front_port_type_choices': PortTypeChoices,
'rear_port_type_choices': PortTypeChoices,
}

View File

@@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0209_interface_tx_power_negative'),
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]

View File

@@ -1,54 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='dcim_platform_manufacturer_name'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('name',),
name='dcim_platform_name',
violation_error_message='Platform name must be unique.'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='dcim_platform_manufacturer_slug'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('slug',),
name='dcim_platform_slug',
violation_error_message='Platform slug must be unique.'
),
),
]

View File

@@ -0,0 +1,287 @@
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import OuterRef, Subquery
def populate_denormalized_data(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
component_models = (
apps.get_model('dcim', 'ConsolePort'),
apps.get_model('dcim', 'ConsoleServerPort'),
apps.get_model('dcim', 'PowerPort'),
apps.get_model('dcim', 'PowerOutlet'),
apps.get_model('dcim', 'Interface'),
apps.get_model('dcim', 'FrontPort'),
apps.get_model('dcim', 'RearPort'),
apps.get_model('dcim', 'DeviceBay'),
apps.get_model('dcim', 'ModuleBay'),
apps.get_model('dcim', 'InventoryItem'),
)
for model in component_models:
subquery = Device.objects.filter(pk=OuterRef('device_id'))
model.objects.update(
_site=Subquery(subquery.values('site_id')[:1]),
_location=Subquery(subquery.values('location_id')[:1]),
_rack=Subquery(subquery.values('rack_id')[:1]),
)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0208_devicerole_uniqueness'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='consoleport',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='consoleport',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='consoleserverport',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='consoleserverport',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='consoleserverport',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='devicebay',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='devicebay',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='devicebay',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='frontport',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='frontport',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='frontport',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='interface',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='interface',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='interface',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='inventoryitem',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='inventoryitem',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='inventoryitem',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='modulebay',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='modulebay',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='modulebay',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='poweroutlet',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='poweroutlet',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='poweroutlet',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='powerport',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='powerport',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='powerport',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.AddField(
model_name='rearport',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.location',
),
),
migrations.AddField(
model_name='rearport',
name='_rack',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
),
),
migrations.AddField(
model_name='rearport',
name='_site',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
),
),
migrations.RunPython(populate_denormalized_data),
]

View File

@@ -1,24 +0,0 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0208_platform_manufacturer_uniqueness'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='tx_power',
field=models.SmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(-40),
django.core.validators.MaxValueValidator(127)
]
),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0209_device_component_denorm_site_location'),
]
operations = [
migrations.AlterModelOptions(
name='macaddress',
options={
'ordering': ('mac_address', 'pk'),
'verbose_name': 'MAC address',
'verbose_name_plural': 'MAC addresses'
},
),
]

View File

@@ -11,6 +11,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest
@@ -155,6 +156,15 @@ class Cable(PrimaryModel):
self._terminations_modified = True
self._b_terminations = value
@property
def color_name(self):
color_name = ""
for hex_code, label in ColorChoices.CHOICES:
if hex_code.lower() == self.color.lower():
color_name = str(label)
return color_name
def clean(self):
super().clean()

View File

@@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
blank=True
)
# Denormalized references replicated from the parent Device
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
class Meta:
abstract = True
ordering = ('device', 'name')
@@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
"device": _("Components cannot be moved to a different device.")
})
def save(self, *args, **kwargs):
# Save denormalized references
self._site = self.device.site
self._location = self.device.location
self._rack = self.device.rack
super().save(*args, **kwargs)
@property
def parent_object(self):
return self.device
@@ -719,13 +750,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
tx_power = models.SmallIntegerField(
tx_power = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(-40),
MaxValueValidator(127),
),
validators=(MaxValueValidator(127),),
verbose_name=_('transmit power (dBm)')
)
poe_mode = models.CharField(

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
from django.db.models import F, ProtectedError, prefetch_related_objects
from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
@@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import RenderConfigMixin
@@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
)
# Override name & slug from OrganizationalModel to not enforce uniqueness
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
@@ -458,28 +450,6 @@ class Platform(OrganizationalModel):
ordering = ('name',)
verbose_name = _('platform')
verbose_name_plural = _('platforms')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='%(app_label)s_%(class)s_manufacturer_name',
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform name must be unique.")
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_manufacturer_slug',
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform slug must be unique.")
),
)
class Device(
@@ -955,7 +925,10 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
model.objects.bulk_create(components)
components = model.objects.bulk_create(components)
# Prefetch related objects to minimize queries needed during post_save
prefetch_fields = get_prefetchable_fields(model)
prefetch_related_objects(components, *prefetch_fields)
# Manually send the post_save signal for each of the newly created components
for component in components:
post_save.send(
@@ -1303,7 +1276,7 @@ class MACAddress(PrimaryModel):
)
class Meta:
ordering = ('mac_address',)
ordering = ('mac_address', 'pk',)
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')

View File

@@ -1,38 +0,0 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkAddComponents',
'BulkDisconnect',
)
class BulkAddComponents(ObjectAction):
"""
Add components to the selected devices.
"""
label = _('Add Components')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_add_components.html'
@classmethod
def get_context(cls, context, obj):
return {
'perms': context.get('perms'),
'request': context.get('request'),
'formaction': context.get('formaction'),
'label': cls.label,
}
class BulkDisconnect(ObjectAction):
"""
Disconnect each of a set of objects to which a cable is connected.
"""
name = 'bulk_disconnect'
label = _('Disconnect Selected')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_disconnect.html'

View File

@@ -3,13 +3,28 @@ import logging
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from dcim.choices import CableEndChoices, LinkStatusChoices
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
COMPONENT_MODELS = (
ConsolePort,
ConsoleServerPort,
DeviceBay,
FrontPort,
Interface,
InventoryItem,
ModuleBay,
PowerOutlet,
PowerPort,
RearPort,
)
#
# Location/rack/device assignment
@@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
@receiver(post_save, sender=Device)
def handle_device_site_change(instance, created, **kwargs):
"""
Update child components to update the parent Site, Location, and Rack when a Device is saved.
"""
if not created:
for model in COMPONENT_MODELS:
model.objects.filter(device=instance).update(
_site=instance.site,
_location=instance.location,
_rack=instance.rack,
)
#
# Virtual chassis
#

View File

@@ -3,6 +3,7 @@ import svgwrite
from svgwrite.container import Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.masking import ClipPath
from svgwrite.shapes import Rect
from svgwrite.text import Text
@@ -67,6 +68,20 @@ def get_device_description(device):
return description
def truncate_text(text, width, font_size=15):
"""
Truncate text to fit within the width of a rectangle.
:param text: The text to truncate
:param width: Width of rectangle
:param font_size: Font size (default is 15, ~0.875rem)
"""
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
max_char = int(width / char_width)
return text if len(text) <= max_char else text[:max_char] + '...'
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
@@ -177,12 +192,26 @@ class RackElevationSVG:
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
link.set_desc(description)
# Create clipPath element
# This is necessary as fallback because the truncate_text method is an approximation
clip_id = f"clip-{device.id}"
clip_path = ClipPath(id=clip_id)
clip_path.add(Rect(coords, size))
self.drawing.defs.add(clip_path)
# Name to display
display_name = truncate_text(name, size[0])
# Add rect element to hyperlink
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
link.add(
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
class_=f'label{css_extra}')
)
# Embed device type image if provided
if self.include_images and image:

View File

@@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
order_by=('_abs_length')
)
color = columns.ColorColumn()
color_name = tables.Column(
verbose_name=_('Color Name'),
orderable=False
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:cable_list'
@@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
'length', 'description', 'comments', 'tags', 'created', 'last_updated',
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

View File

@@ -3367,9 +3367,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
ConsoleServerPort.objects.bulk_create(console_server_ports)
console_ports = (
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
ConsolePort(
device=devices[0],
module=modules[0],
name='Console Port 1',
label='A',
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
ConsolePort(
device=devices[1],
module=modules[1],
name='Console Port 2',
label='B',
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
ConsolePort(
device=devices[2],
module=modules[2],
name='Console Port 3',
label='C',
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
)
ConsolePort.objects.bulk_create(console_ports)
@@ -3581,13 +3608,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
console_server_ports = (
ConsoleServerPort(
device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
device=devices[0],
module=modules[0],
name='Console Server Port 1',
label='A',
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
ConsoleServerPort(
device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
device=devices[1],
module=modules[1],
name='Console Server Port 2',
label='B',
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
ConsoleServerPort(
device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
device=devices[2],
module=modules[2],
name='Console Server Port 3',
label='C',
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
)
ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -3807,6 +3855,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=100,
allocated_draw=50,
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
PowerPort(
device=devices[1],
@@ -3816,6 +3867,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=200,
allocated_draw=100,
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
PowerPort(
device=devices[2],
@@ -3825,6 +3879,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=300,
allocated_draw=150,
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
)
PowerPort.objects.bulk_create(power_ports)
@@ -4053,6 +4110,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='First',
color='ff0000',
status=PowerOutletStatusChoices.STATUS_ENABLED,
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
PowerOutlet(
device=devices[1],
@@ -4063,6 +4123,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='Second',
color='00ff00',
status=PowerOutletStatusChoices.STATUS_DISABLED,
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
PowerOutlet(
device=devices[2],
@@ -4073,6 +4136,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='Third',
color='0000ff',
status=PowerOutletStatusChoices.STATUS_FAULTY,
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
)
PowerOutlet.objects.bulk_create(power_outlets)
@@ -4381,13 +4447,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
vlan_translation_policy=vlan_translation_policies[0],
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
Interface(
device=devices[1],
module=modules[1],
name='VC Chassis Interface',
type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True
enabled=True,
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
Interface(
device=devices[2],
@@ -4406,6 +4478,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
vlan_translation_policy=vlan_translation_policies[0],
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
Interface(
device=devices[3],
@@ -4424,6 +4499,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
vlan_translation_policy=vlan_translation_policies[1],
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
Interface(
device=devices[4],
@@ -4440,6 +4518,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[0],
vlan_translation_policy=vlan_translation_policies[1],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
),
Interface(
device=devices[4],
@@ -4450,7 +4531,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=True,
tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[1]
qinq_svlan=vlans[1],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
),
Interface(
device=devices[4],
@@ -4461,7 +4545,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=False,
tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[2]
qinq_svlan=vlans[2],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
),
Interface(
device=devices[4],
@@ -4470,7 +4557,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412,
rf_channel_width=22
rf_channel_width=22,
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
),
Interface(
device=devices[4],
@@ -4479,7 +4569,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
rf_channel_width=20,
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
),
)
Interface.objects.bulk_create(interfaces)
@@ -4906,6 +4999,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rear_port=rear_ports[0],
rear_port_position=1,
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
FrontPort(
device=devices[1],
@@ -4917,6 +5013,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rear_port=rear_ports[1],
rear_port_position=2,
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
FrontPort(
device=devices[2],
@@ -4928,6 +5027,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rear_port=rear_ports[2],
rear_port_position=3,
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
FrontPort(
device=devices[3],
@@ -4936,6 +5038,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[3],
rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
FrontPort(
device=devices[3],
@@ -4944,6 +5049,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[4],
rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
FrontPort(
device=devices[3],
@@ -4952,6 +5060,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[5],
rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
)
FrontPort.objects.bulk_create(front_ports)
@@ -5168,6 +5279,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_RED,
positions=1,
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
RearPort(
device=devices[1],
@@ -5178,6 +5292,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_GREEN,
positions=2,
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
RearPort(
device=devices[2],
@@ -5188,10 +5305,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_BLUE,
positions=3,
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
RearPort(
device=devices[3],
name='Rear Port 4',
label='D',
type=PortTypeChoices.TYPE_FC,
positions=4,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
RearPort(
device=devices[3],
name='Rear Port 5',
label='E',
type=PortTypeChoices.TYPE_FC,
positions=5,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
RearPort(
device=devices[3],
name='Rear Port 6',
label='F',
type=PortTypeChoices.TYPE_FC,
positions=6,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
),
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
)
RearPort.objects.bulk_create(rear_ports)
@@ -5550,9 +5697,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Device.objects.bulk_create(devices)
device_bays = (
DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
DeviceBay(
device=devices[0],
name='Device Bay 1',
label='A',
description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
),
DeviceBay(
device=devices[1],
name='Device Bay 2',
label='B',
description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
),
DeviceBay(
device=devices[2],
name='Device Bay 3',
label='C',
description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
),
)
DeviceBay.objects.bulk_create(device_bays)

View File

@@ -3266,17 +3266,27 @@ class CableTestCase(
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
# NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
# different sites.
# The CSV test below demonstrates that devices with identical names on different sites can be created
# and referenced successfully.
devices = (
Device(name='Device 1', site=site, device_type=devicetype, role=role),
Device(name='Device 2', site=site, device_type=devicetype, role=role),
Device(name='Device 3', site=site, device_type=devicetype, role=role),
Device(name='Device 4', site=site, device_type=devicetype, role=role),
# Create 'Device 1' assigned to 'Site 1'
Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
)
Device.objects.bulk_create(devices)
@@ -3327,13 +3337,15 @@ class CableTestCase(
'tags': [t.pk for t in tags],
}
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
# the same device name, provided those devices belong to different sites.
cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
)
cls.csv_update_data = (

View File

@@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -34,7 +34,6 @@ from wireless.models import WirelessLAN
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
from .object_actions import BulkAddComponents, BulkDisconnect
CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort,
@@ -50,6 +49,11 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
}
queryset = Device.objects.all()
def get_children(self, request, parent):
@@ -57,8 +61,12 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -70,9 +78,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
}
class ModuleTypeComponentsView(generic.ObjectChildrenView):
class ModuleTypeComponentsView(DeviceComponentsView):
queryset = ModuleType.objects.all()
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/moduletype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -292,11 +300,6 @@ class RegionBulkEditView(generic.BulkEditView):
form = forms.RegionBulkEditForm
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
class RegionBulkRenameView(generic.BulkRenameView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_delete', path='delete', detail=False)
class RegionBulkDeleteView(generic.BulkDeleteView):
queryset = Region.objects.add_related_count(
@@ -423,11 +426,6 @@ class SiteGroupBulkEditView(generic.BulkEditView):
form = forms.SiteGroupBulkEditForm
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
class SiteGroupBulkRenameView(generic.BulkRenameView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
class SiteGroupBulkDeleteView(generic.BulkDeleteView):
queryset = SiteGroup.objects.add_related_count(
@@ -513,11 +511,6 @@ class SiteBulkEditView(generic.BulkEditView):
form = forms.SiteBulkEditForm
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
class SiteBulkRenameView(generic.BulkRenameView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_delete', path='delete', detail=False)
class SiteBulkDeleteView(generic.BulkDeleteView):
queryset = Site.objects.all()
@@ -622,11 +615,6 @@ class LocationBulkEditView(generic.BulkEditView):
form = forms.LocationBulkEditForm
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
class LocationBulkRenameView(generic.BulkRenameView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_delete', path='delete', detail=False)
class LocationBulkDeleteView(generic.BulkDeleteView):
queryset = Location.objects.add_related_count(
@@ -692,11 +680,6 @@ class RackRoleBulkEditView(generic.BulkEditView):
form = forms.RackRoleBulkEditForm
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
class RackRoleBulkRenameView(generic.BulkRenameView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
@@ -756,12 +739,6 @@ class RackTypeBulkEditView(generic.BulkEditView):
form = forms.RackTypeBulkEditForm
@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
class RackTypeBulkRenameView(generic.BulkRenameView):
queryset = RackType.objects.all()
field_name = 'model'
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all()
@@ -941,11 +918,6 @@ class RackBulkEditView(generic.BulkEditView):
form = forms.RackBulkEditForm
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
class RackBulkRenameView(generic.BulkRenameView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.all()
@@ -963,7 +935,6 @@ class RackReservationListView(generic.ObjectListView):
filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(RackReservation)
@@ -1080,11 +1051,6 @@ class ManufacturerBulkEditView(generic.BulkEditView):
form = forms.ManufacturerBulkEditForm
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
class ManufacturerBulkRenameView(generic.BulkRenameView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
@@ -1332,12 +1298,6 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
form = forms.DeviceTypeBulkEditForm
@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
class DeviceTypeBulkRenameView(generic.BulkRenameView):
queryset = DeviceType.objects.all()
field_name = 'model'
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.annotate(
@@ -1394,11 +1354,6 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeProfileBulkEditForm
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleTypeProfile.objects.annotate(
@@ -1609,11 +1564,6 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeBulkEditForm
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.annotate(
@@ -2088,11 +2038,6 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
form = forms.DeviceRoleBulkEditForm
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
class DeviceRoleBulkRenameView(generic.BulkRenameView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.annotate(
@@ -2154,11 +2099,6 @@ class PlatformBulkEditView(generic.BulkEditView):
form = forms.PlatformBulkEditForm
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
class PlatformBulkRenameView(generic.BulkRenameView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
@@ -2176,7 +2116,7 @@ class DeviceListView(generic.ObjectListView):
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable
actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device_list.html'
@register_model_view(Device)
@@ -2217,7 +2157,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/consoleports.html',
tab = ViewTab(
label=_('Console Ports'),
badge=lambda obj: obj.console_port_count,
@@ -2233,7 +2173,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab(
label=_('Console Server Ports'),
badge=lambda obj: obj.console_server_port_count,
@@ -2249,7 +2189,7 @@ class DevicePowerPortsView(DeviceComponentsView):
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/powerports.html'
tab = ViewTab(
label=_('Power Ports'),
badge=lambda obj: obj.power_port_count,
@@ -2265,7 +2205,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab(
label=_('Power Outlets'),
badge=lambda obj: obj.power_outlet_count,
@@ -2281,7 +2221,6 @@ class DeviceInterfacesView(DeviceComponentsView):
table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/interfaces.html'
tab = ViewTab(
label=_('Interfaces'),
@@ -2304,7 +2243,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/frontports.html'
tab = ViewTab(
label=_('Front Ports'),
badge=lambda obj: obj.front_port_count,
@@ -2320,7 +2259,7 @@ class DeviceRearPortsView(DeviceComponentsView):
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/rearports.html'
tab = ViewTab(
label=_('Rear Ports'),
badge=lambda obj: obj.rear_port_count,
@@ -2336,7 +2275,11 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/modulebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count,
@@ -2352,7 +2295,11 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/devicebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count,
@@ -2368,7 +2315,11 @@ class DeviceInventoryView(DeviceComponentsView):
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count,
@@ -2442,16 +2393,16 @@ class DeviceBulkEditView(generic.BulkEditView):
form = forms.DeviceBulkEditForm
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.all()
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.prefetch_related('device_type__manufacturer')
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.all()
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@@ -2466,7 +2417,6 @@ class ModuleListView(generic.ObjectListView):
filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Module)
@@ -2522,6 +2472,11 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsolePort)
@@ -2592,6 +2547,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsoleServerPort)
@@ -2662,6 +2622,11 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerPort)
@@ -2732,6 +2697,11 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerOutlet)
@@ -2802,6 +2772,11 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(Interface)
@@ -2945,6 +2920,11 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(FrontPort)
@@ -3015,6 +2995,11 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(RearPort)
@@ -3085,6 +3070,11 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ModuleBay)
@@ -3146,6 +3136,11 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(DeviceBay)
@@ -3288,6 +3283,11 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(InventoryItem)
@@ -3410,11 +3410,6 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
form = forms.InventoryItemRoleBulkEditForm
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
@@ -3612,12 +3607,6 @@ class CableBulkEditView(generic.BulkEditView):
form = forms.CableBulkEditForm
@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
class CableBulkRenameView(generic.BulkRenameView):
queryset = Cable.objects.all()
field_name = 'label'
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related(
@@ -3638,7 +3627,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3652,7 +3643,9 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3666,7 +3659,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3910,11 +3905,6 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
form = forms.VirtualChassisBulkEditForm
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
class VirtualChassisBulkRenameView(generic.BulkRenameView):
queryset = VirtualChassis.objects.all()
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualChassis.objects.all()
@@ -3972,11 +3962,6 @@ class PowerPanelBulkEditView(generic.BulkEditView):
form = forms.PowerPanelBulkEditForm
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
class PowerPanelBulkRenameView(generic.BulkRenameView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.annotate(
@@ -4029,11 +4014,6 @@ class PowerFeedBulkEditView(generic.BulkEditView):
form = forms.PowerFeedBulkEditForm
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
class PowerFeedBulkRenameView(generic.BulkRenameView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
class PowerFeedBulkDisconnectView(BulkDisconnectView):
queryset = PowerFeed.objects.all()
@@ -4062,7 +4042,6 @@ class VirtualDeviceContextListView(generic.ObjectListView):
filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
@register_model_view(VirtualDeviceContext)
@@ -4107,11 +4086,6 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
form = forms.VirtualDeviceContextBulkEditForm
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDeviceContext.objects.all()
@@ -4129,7 +4103,6 @@ class MACAddressListView(generic.ObjectListView):
filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(MACAddress)

View File

@@ -1,3 +1,4 @@
from .serializers_.objecttypes import *
from .serializers_.attachments import *
from .serializers_.bookmarks import *
from .serializers_.customfields import *

View File

@@ -0,0 +1,16 @@
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer
__all__ = (
'ObjectTypeSerializer',
)
class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
class Meta:
model = ObjectType
fields = ['id', 'url', 'display', 'app_label', 'model']

View File

@@ -1,6 +1,5 @@
from django.urls import include, path
from core.api.views import ObjectTypeViewSet
from netbox.api.routers import NetBoxRouter
from . import views
@@ -27,9 +26,7 @@ router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script')
# TODO: Remove in NetBox v4.5
router.register('object-types', ObjectTypeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
app_name = 'extras-api'
urlpatterns = [

View File

@@ -10,9 +10,10 @@ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rq import Worker
from core.models import ObjectType
from extras import filtersets
from extras.jobs import ScriptJob
from extras.models import *
@@ -184,7 +185,9 @@ class TagViewSet(NetBoxModelViewSet):
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
queryset = TaggedItem.objects.prefetch_related(
'content_type', 'content_object', 'tag'
).order_by('tag__weight', 'tag__name')
serializer_class = serializers.TaggedItemSerializer
filterset_class = filtersets.TaggedItemFilterSet
@@ -313,6 +316,20 @@ class ScriptViewSet(ModelViewSet):
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#
# Object types
#
class ObjectTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ObjectType.objects.order_by('app_label', 'model')
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
#
# User dashboard
#

View File

@@ -21,6 +21,12 @@ WEBHOOK_EVENT_TYPES = {
JOB_ERRORED: 'job_ended',
}
# Jinja environment parameters which support path imports
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
'undefined',
'finalize',
)
# Dashboard
DEFAULT_DASHBOARD = [
{

View File

@@ -309,6 +309,7 @@ class RSSFeedWidget(DashboardWidget):
default_config = {
'max_entries': 10,
'cache_timeout': 3600, # seconds
'request_timeout': 3, # seconds
'requires_internet': True,
}
description = _('Embed an RSS feed from an external website.')
@@ -335,6 +336,12 @@ class RSSFeedWidget(DashboardWidget):
max_value=86400, # 24 hours
help_text=_('How long to stored the cached content (in seconds)')
)
request_timeout = forms.IntegerField(
min_value=1,
max_value=60,
required=False,
help_text=_('Timeout value for fetching the feed (in seconds)')
)
def render(self, request):
return render_to_string(self.template_name, {
@@ -366,7 +373,7 @@ class RSSFeedWidget(DashboardWidget):
url=self.config['feed_url'],
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
timeout=3
timeout=self.config.get('request_timeout', 3),
)
response.raise_for_status()
except requests.exceptions.RequestException as e:

View File

@@ -29,6 +29,7 @@ __all__ = (
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'NotificationGroupFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
'TableConfigFilterSet',
@@ -787,3 +788,26 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
def _local_context_data(self, queryset, name, value):
return queryset.exclude(local_context_data__isnull=value)
#
# ContentTypes
#
class ObjectTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)

View File

@@ -90,10 +90,7 @@ class ScriptJob(JobRunner):
request: The WSGI request associated with this execution (if any)
commit: Passed through to Script.run()
"""
script_model = ScriptModel.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
script = script_model.python_class()
self.logger.debug(f"Loaded script {script.full_name}")
script = ScriptModel.objects.get(pk=self.job.object_id).python_class()
# Add files to form data
if request:
@@ -103,7 +100,6 @@ class ScriptJob(JobRunner):
# Add the current request as a property of the script
script.request = request
self.logger.debug(f"Request ID: {request.id}")
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc.

View File

@@ -14,16 +14,9 @@ from utilities.proxy import resolve_proxies
class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
def handle(self, *args, **options):
self.stdout.write(
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.",
self.style.WARNING
)
config = Config()
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)

View File

@@ -2,16 +2,17 @@ import importlib.abc
import importlib.util
import os
import sys
from django.core.files.storage import storages
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from extras.constants import DEFAULT_MIME_TYPE
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
__all__ = (
'PythonModuleMixin',
'RenderTemplateMixin',
@@ -125,12 +126,22 @@ class RenderTemplateMixin(models.Model):
class_name=self.__class__
))
def get_environment_params(self):
"""
Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
"""
params = self.environment_params or {}
for name, value in params.items():
if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
params[name] = import_string(value)
return params
def render(self, context=None, queryset=None):
"""
Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
"""
context = self.get_context(context=context, queryset=queryset)
env_params = self.environment_params or {}
env_params = self.get_environment_params()
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
# Replace CRLF-style line terminators

View File

@@ -849,6 +849,9 @@ class Bookmark(models.Model):
return str(self.object)
return super().__str__()
def get_absolute_url(self):
return reverse('account:bookmarks')
def clean(self):
super().clean()

View File

@@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase):
indexes = [models.Index(fields=["content_type", "object_id"])]
verbose_name = _('tagged item')
verbose_name_plural = _('tagged items')
# Note: while there is no ordering applied here (because it would basically be done on fields
# of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
# to allow for proper pagination.

View File

@@ -317,11 +317,12 @@ class TableConfigTable(NetBoxTable):
class BookmarkTable(NetBoxTable):
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Types'),
verbose_name=_('Object Type'),
)
object = tables.Column(
verbose_name=_('Object'),
linkify=True
linkify=True,
orderable=False
)
actions = columns.ActionsColumn(
actions=('delete',)

View File

@@ -3,6 +3,7 @@ import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -920,6 +921,22 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
class ObjectTypeTest(APITestCase):
def test_list_objects(self):
object_type_count = ObjectType.objects.count()
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], object_type_count)
def test_get_object(self):
object_type = ObjectType.objects.first()
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
class SubscriptionTest(APIViewTestCases.APIViewTestCase):
model = Subscription
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']

View File

@@ -807,3 +807,21 @@ class NotificationTestCase(
def test_list_objects_with_constrained_permission(self):
return
class ScriptListViewTest(TestCase):
user_permissions = ['extras.view_script']
def test_script_list_embedded_parameter(self):
"""Test that ScriptListView accepts embedded parameter without error"""
url = reverse('extras:script_list')
# Test normal request
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'extras/script_list.html')
# Test embedded request
response = self.client.get(url, {'embedded': 'true'})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')

View File

@@ -14,13 +14,12 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.models import Job
from core.object_actions import BulkSync
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin
from netbox.object_actions import *
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
@@ -97,11 +96,6 @@ class CustomFieldBulkEditView(generic.BulkEditView):
form = forms.CustomFieldBulkEditForm
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
class CustomFieldBulkRenameView(generic.BulkRenameView):
queryset = CustomField.objects.all()
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.select_related('choice_set')
@@ -171,11 +165,6 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
form = forms.CustomFieldChoiceSetBulkEditForm
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
@@ -226,11 +215,6 @@ class CustomLinkBulkEditView(generic.BulkEditView):
form = forms.CustomLinkBulkEditForm
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
class CustomLinkBulkRenameView(generic.BulkRenameView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
class CustomLinkBulkDeleteView(generic.BulkDeleteView):
queryset = CustomLink.objects.all()
@@ -248,7 +232,11 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable
actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/exporttemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ExportTemplate)
@@ -282,11 +270,6 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
form = forms.ExportTemplateBulkEditForm
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
class ExportTemplateBulkRenameView(generic.BulkRenameView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ExportTemplate.objects.all()
@@ -347,11 +330,6 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.SavedFilterBulkEditForm
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
class SavedFilterBulkRenameView(generic.BulkRenameView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all()
@@ -369,7 +347,9 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable
actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
actions = {
'export': {'view'},
}
@register_model_view(TableConfig)
@@ -409,11 +389,6 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.TableConfigBulkEditForm
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
class TableConfigBulkRenameView(generic.BulkRenameView):
queryset = TableConfig.objects.all()
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = TableConfig.objects.all()
@@ -495,11 +470,6 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
form = forms.NotificationGroupBulkEditForm
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
class NotificationGroupBulkRenameView(generic.BulkRenameView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
queryset = NotificationGroup.objects.all()
@@ -646,11 +616,6 @@ class WebhookBulkEditView(generic.BulkEditView):
form = forms.WebhookBulkEditForm
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
class WebhookBulkRenameView(generic.BulkRenameView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
class WebhookBulkDeleteView(generic.BulkDeleteView):
queryset = Webhook.objects.all()
@@ -701,11 +666,6 @@ class EventRuleBulkEditView(generic.BulkEditView):
form = forms.EventRuleBulkEditForm
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
class EventRuleBulkRenameView(generic.BulkRenameView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
class EventRuleBulkDeleteView(generic.BulkDeleteView):
queryset = EventRule.objects.all()
@@ -780,11 +740,6 @@ class TagBulkEditView(generic.BulkEditView):
form = forms.TagBulkEditForm
@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
class TagBulkRenameView(generic.BulkRenameView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate(
@@ -803,7 +758,13 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/configcontext_list.html'
actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
@register_model_view(ConfigContext)
@@ -864,11 +825,6 @@ class ConfigContextBulkEditView(generic.BulkEditView):
form = forms.ConfigContextBulkEditForm
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
class ConfigContextBulkRenameView(generic.BulkRenameView):
queryset = ConfigContext.objects.all()
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
@@ -921,7 +877,11 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable
actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/configtemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ConfigTemplate)
@@ -955,11 +915,6 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
form = forms.ConfigTemplateBulkEditForm
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigTemplate.objects.all()
@@ -1037,7 +992,9 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable
actions = (BulkExport,)
actions = {
'export': {'view'},
}
@register_model_view(ImageAttachment, 'add', detail=False)
@@ -1081,7 +1038,12 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
actions = (BulkImport, BulkEdit, BulkDelete)
actions = {
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(JournalEntry)
@@ -1320,11 +1282,18 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
'data_source', 'data_file', 'jobs'
)
return render(request, 'extras/script_list.html', {
context = {
'model': ScriptModule,
'script_modules': script_modules,
})
}
# Use partial template for dashboard widgets
template_name = 'extras/script_list.html'
if request.GET.get('embedded'):
template_name = 'extras/inc/script_list_content.html'
context['embedded'] = True
return render(request, template_name, context)
class BaseScriptView(generic.ObjectView):

View File

@@ -20,9 +20,8 @@ from utilities.forms.fields import (
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.utils import get_field_value
from utilities.forms.widgets import DatePicker, HTMXSelect
from django.utils.safestring import mark_safe
from utilities.templatetags.builtins.filters import bettertitle
from virtualization.models import VMInterface
from virtualization.models import VMInterface, VirtualMachine
__all__ = (
'AggregateForm',
@@ -681,15 +680,7 @@ class VLANForm(TenancyForm, NetBoxModelForm):
queryset=Site.objects.all(),
required=False,
null_option='None',
selector=True,
help_text=mark_safe(
'<span class="text-warning"><i class="mdi mdi-alert"></i> {text}</span>'.format(
text=_(
'The direct assignment of VLANs to a site is deprecated and will be removed in a future release. '
'Users are encouraged to utilize VLAN groups for this purpose.'
)
)
)
selector=True
)
role = DynamicModelChoiceField(
label=_('Role'),
@@ -792,10 +783,6 @@ class ServiceForm(NetBoxModelForm):
queryset=IPAddress.objects.all(),
required=False,
label=_('IP Addresses'),
query_params={
'device_id': '$device',
'virtual_machine_id': '$virtual_machine',
}
)
comments = CommentField()
@@ -824,10 +811,22 @@ class ServiceForm(NetBoxModelForm):
super().__init__(*args, **kwargs)
if (parent_object_type_id := get_field_value(self, 'parent_object_type')):
if parent_object_type_id := get_field_value(self, 'parent_object_type'):
try:
parent_type = ContentType.objects.get(pk=parent_object_type_id)
model = parent_type.model_class()
if model == Device:
self.fields['ipaddresses'].widget.add_query_params({
'device_id': '$parent',
})
elif model == VirtualMachine:
self.fields['ipaddresses'].widget.add_query_params({
'virtual_machine_id': '$parent',
})
elif model == FHRPGroup:
self.fields['ipaddresses'].widget.add_query_params({
'fhrpgroup_id': '$parent',
})
self.fields['parent'].queryset = model.objects.all()
self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
self.fields['parent'].disabled = False

View File

@@ -222,6 +222,19 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
return Q()
return q
@strawberry_django.filter_field()
def contains(self, value: list[str], prefix) -> Q:
if not value:
return Q()
q = Q()
for subnet in value:
net = netaddr.IPNetwork(subnet.strip())
q |= Q(
start_address__host__inet__lte=str(netaddr.IPAddress(net.first)),
end_address__host__inet__gte=str(netaddr.IPAddress(net.last)),
)
return q
@strawberry_django.filter_type(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
@@ -238,6 +251,16 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_field()
def contains(self, value: list[str], prefix) -> Q:
if not value:
return Q()
q = Q()
for subnet in value:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
q |= Q(prefix__net_contains=query)
return q
@strawberry_django.filter_type(models.RIR, lookups=True)
class RIRFilter(OrganizationalModelFilterMixin):

View File

@@ -0,0 +1,20 @@
from django.contrib.postgres.indexes import GistIndex
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0210_macaddress_ordering'),
('extras', '0129_fix_script_paths'),
('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'),
('tenancy', '0020_remove_contactgroupmembership'),
]
operations = [
migrations.AddIndex(
model_name='prefix',
index=GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
),
]

View File

@@ -1,5 +1,6 @@
import netaddr
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.indexes import GistIndex
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F
@@ -281,6 +282,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
verbose_name = _('prefix')
verbose_name_plural = _('prefixes')
indexes = [
GistIndex(
fields=['prefix'],
name='ipam_prefix_gist_idx',
opclasses=['inet_ops'],
),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -10,7 +10,6 @@ from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic
from utilities.query import count_related
from utilities.tables import get_table_ordering
@@ -87,11 +86,6 @@ class VRFBulkEditView(generic.BulkEditView):
form = forms.VRFBulkEditForm
@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
class VRFBulkRenameView(generic.BulkRenameView):
queryset = VRF.objects.all()
@register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
class VRFBulkDeleteView(generic.BulkDeleteView):
queryset = VRF.objects.all()
@@ -142,11 +136,6 @@ class RouteTargetBulkEditView(generic.BulkEditView):
form = forms.RouteTargetBulkEditForm
@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
class RouteTargetBulkRenameView(generic.BulkRenameView):
queryset = RouteTarget.objects.all()
@register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
queryset = RouteTarget.objects.all()
@@ -206,11 +195,6 @@ class RIRBulkEditView(generic.BulkEditView):
form = forms.RIRBulkEditForm
@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
class RIRBulkRenameView(generic.BulkRenameView):
queryset = RIR.objects.all()
@register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate(
@@ -284,11 +268,6 @@ class ASNRangeBulkEditView(generic.BulkEditView):
form = forms.ASNRangeBulkEditForm
@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
class ASNRangeBulkRenameView(generic.BulkRenameView):
queryset = ASNRange.objects.all()
@register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
queryset = ASNRange.objects.annotate_asn_counts()
@@ -356,11 +335,6 @@ class ASNBulkEditView(generic.BulkEditView):
form = forms.ASNBulkEditForm
@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
class ASNBulkRenameView(generic.BulkRenameView):
queryset = ASN.objects.all()
@register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
class ASNBulkDeleteView(generic.BulkDeleteView):
queryset = ASN.objects.annotate(
@@ -382,7 +356,6 @@ class AggregateListView(generic.ObjectListView):
filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Aggregate)
@@ -515,11 +488,6 @@ class RoleBulkEditView(generic.BulkEditView):
form = forms.RoleBulkEditForm
@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
class RoleBulkRenameView(generic.BulkRenameView):
queryset = Role.objects.all()
@register_model_view(Role, 'bulk_delete', path='delete', detail=False)
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
@@ -538,7 +506,6 @@ class PrefixListView(generic.ObjectListView):
filterset_form = forms.PrefixFilterForm
table = tables.PrefixTable
template_name = 'ipam/prefix_list.html'
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Prefix)
@@ -799,11 +766,6 @@ class IPRangeBulkEditView(generic.BulkEditView):
form = forms.IPRangeBulkEditForm
@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
class IPRangeBulkRenameView(generic.BulkRenameView):
queryset = IPRange.objects.all()
@register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
class IPRangeBulkDeleteView(generic.BulkDeleteView):
queryset = IPRange.objects.all()
@@ -821,7 +783,6 @@ class IPAddressListView(generic.ObjectListView):
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(IPAddress)
@@ -1045,11 +1006,6 @@ class VLANGroupBulkEditView(generic.BulkEditView):
form = forms.VLANGroupBulkEditForm
@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
class VLANGroupBulkRenameView(generic.BulkRenameView):
queryset = VLANGroup.objects.all()
@register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
@@ -1139,11 +1095,6 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
form = forms.VLANTranslationPolicyBulkEditForm
@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
queryset = VLANTranslationPolicy.objects.all()
@register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
queryset = VLANTranslationPolicy.objects.all()
@@ -1161,7 +1112,6 @@ class VLANTranslationRuleListView(generic.ObjectListView):
filterset = filtersets.VLANTranslationRuleFilterSet
filterset_form = forms.VLANTranslationRuleFilterForm
table = tables.VLANTranslationRuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VLANTranslationRule)
@@ -1294,11 +1244,6 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
form = forms.FHRPGroupBulkEditForm
@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
class FHRPGroupBulkRenameView(generic.BulkRenameView):
queryset = FHRPGroup.objects.all()
@register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
queryset = FHRPGroup.objects.all()
@@ -1426,11 +1371,6 @@ class VLANBulkEditView(generic.BulkEditView):
form = forms.VLANBulkEditForm
@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
class VLANBulkRenameView(generic.BulkRenameView):
queryset = VLAN.objects.all()
@register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
class VLANBulkDeleteView(generic.BulkDeleteView):
queryset = VLAN.objects.all()
@@ -1481,11 +1421,6 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
form = forms.ServiceTemplateBulkEditForm
@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
class ServiceTemplateBulkRenameView(generic.BulkRenameView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ServiceTemplate.objects.all()
@@ -1553,11 +1488,6 @@ class ServiceBulkEditView(generic.BulkEditView):
form = forms.ServiceBulkEditForm
@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
class ServiceBulkRenameView(generic.BulkRenameView):
queryset = Service.objects.all()
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('parent')

View File

@@ -12,3 +12,7 @@ class SerializerNotFound(Exception):
class GraphQLTypeNotFound(Exception):
pass
class QuerySetNotOrdered(Exception):
pass

View File

@@ -1,6 +1,7 @@
from django.db.models import QuerySet
from rest_framework.pagination import LimitOffsetPagination
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.config import get_config
@@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def paginate_queryset(self, queryset, request, view=None):
if isinstance(queryset, QuerySet) and not queryset.ordered:
raise QuerySetNotOrdered(
"Paginating over an unordered queryset is unreliable. Ensure that a minimal "
"ordering has been applied to the queryset for this API endpoint."
)
if isinstance(queryset, QuerySet):
self.count = self.get_queryset_count(queryset)
else:

View File

@@ -28,8 +28,7 @@ ADVISORY_LOCK_KEYS = {
'job-schedules': 110100,
}
# TODO: Remove in NetBox v4.6
# Legacy default view action permission mapping
# Default view action permission mapping
DEFAULT_ACTION_PERMISSIONS = {
'add': {'add'},
'export': {'view'},
@@ -44,10 +43,3 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'
# CSV delimiters
CSV_DELIMITERS = {
'comma': ',',
'semicolon': ';',
'pipe': '|',
}

View File

@@ -8,16 +8,12 @@ from django_pglocks import advisory_lock
from rq.timeouts import JobTimeoutException
from core.choices import JobStatusChoices
from core.events import JOB_COMPLETED, JOB_FAILED
from core.exceptions import JobFailed
from core.models import Job, ObjectType
from extras.models import Notification
from netbox.constants import ADVISORY_LOCK_KEYS
from netbox.registry import registry
from utilities.request import apply_request_processors
__all__ = (
'AsyncViewJob',
'JobRunner',
'system_job',
)
@@ -39,19 +35,6 @@ def system_job(interval):
return _wrapper
class JobLogHandler(logging.Handler):
"""
A logging handler which records entries on a Job.
"""
def __init__(self, job, *args, **kwargs):
super().__init__(*args, **kwargs)
self.job = job
def emit(self, record):
# Enter the record in the log of the associated Job
self.job.log(record)
class JobRunner(ABC):
"""
Background Job helper class.
@@ -70,11 +53,6 @@ class JobRunner(ABC):
"""
self.job = job
# Initiate the system logger
self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}")
self.logger.setLevel(logging.DEBUG)
self.logger.addHandler(JobLogHandler(job))
@classproperty
def name(cls):
return getattr(cls.Meta, 'name', cls.__name__)
@@ -183,35 +161,3 @@ class JobRunner(ABC):
job.delete()
return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs)
class AsyncViewJob(JobRunner):
"""
Execute a view as a background job.
"""
class Meta:
name = 'Async View'
def run(self, view_cls, request, **kwargs):
view = view_cls.as_view()
# Apply all registered request processors (e.g. event_tracking)
with apply_request_processors(request):
data = view(request)
self.job.data = {
'log': data.log,
'errors': data.errors,
}
# Notify the user
notification = Notification(
user=request.user,
object=self.job,
event_type=JOB_COMPLETED if not data.errors else JOB_FAILED,
)
notification.save()
# TODO: Waiting on fix for bug #19806
# if errors:
# raise JobFailed()

View File

@@ -1,5 +1,8 @@
from contextlib import ExitStack
import logging
import uuid
import warnings
from django.conf import settings
from django.contrib import auth, messages
@@ -10,10 +13,10 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect
from netbox.config import clear_config, get_config
from netbox.registry import registry
from netbox.views import handler_500
from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception
from utilities.request import apply_request_processors
__all__ = (
'CoreMiddleware',
@@ -33,7 +36,12 @@ class CoreMiddleware:
request.id = uuid.uuid4()
# Apply all registered request processors
with apply_request_processors(request):
with ExitStack() as stack:
for request_processor in registry['request_processors']:
try:
stack.enter_context(request_processor(request))
except Exception as e:
warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
response = self.get_response(request)
# Check if language cookie should be renewed

View File

@@ -1,180 +0,0 @@
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.models import ExportTemplate
from utilities.querydict import prepare_cloned_fields
__all__ = (
'AddObject',
'BulkDelete',
'BulkEdit',
'BulkExport',
'BulkImport',
'BulkRename',
'CloneObject',
'DeleteObject',
'EditObject',
'ObjectAction',
)
class ObjectAction:
"""
Base class for single- and multi-object operations.
Params:
name: The action name appended to the module for view resolution
label: Human-friendly label for the rendered button
multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table)
permissions_required: The set of permissions a user must have to perform the action
url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL
"""
name = ''
label = None
multi = False
permissions_required = set()
url_kwargs = []
@classmethod
def get_url(cls, obj):
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}'
kwargs = {
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
}
try:
return reverse(viewname, kwargs=kwargs)
except NoReverseMatch:
return
@classmethod
def get_context(cls, context, obj):
return {
'url': cls.get_url(obj),
'label': cls.label,
}
class AddObject(ObjectAction):
"""
Create a new object.
"""
name = 'add'
label = _('Add')
permissions_required = {'add'}
template_name = 'buttons/add.html'
class CloneObject(ObjectAction):
"""
Populate the new object form with select details from an existing object.
"""
name = 'add'
label = _('Clone')
permissions_required = {'add'}
template_name = 'buttons/clone.html'
@classmethod
def get_context(cls, context, obj):
param_string = prepare_cloned_fields(obj).urlencode()
url = f'{cls.get_url(obj)}?{param_string}' if param_string else None
return {
'url': url,
'label': cls.label,
}
class EditObject(ObjectAction):
"""
Edit a single object.
"""
name = 'edit'
label = _('Edit')
permissions_required = {'change'}
url_kwargs = ['pk']
template_name = 'buttons/edit.html'
class DeleteObject(ObjectAction):
"""
Delete a single object.
"""
name = 'delete'
label = _('Delete')
permissions_required = {'delete'}
url_kwargs = ['pk']
template_name = 'buttons/delete.html'
class BulkImport(ObjectAction):
"""
Import multiple objects at once.
"""
name = 'bulk_import'
label = _('Import')
permissions_required = {'add'}
template_name = 'buttons/import.html'
class BulkExport(ObjectAction):
"""
Export multiple objects at once.
"""
name = 'export'
label = _('Export')
permissions_required = {'view'}
template_name = 'buttons/export.html'
@classmethod
def get_context(cls, context, model):
object_type = ObjectType.objects.get_for_model(model)
user = context['request'].user
# Determine if the "all data" export returns CSV or YAML
data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
# Retrieve all export templates for this model
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
return {
'label': cls.label,
'perms': context['perms'],
'object_type': object_type,
'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
'export_templates': export_templates,
'data_format': data_format,
}
class BulkEdit(ObjectAction):
"""
Change the value of one or more fields on a set of objects.
"""
name = 'bulk_edit'
label = _('Edit Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_edit.html'
class BulkRename(ObjectAction):
"""
Rename multiple objects at once.
"""
name = 'bulk_rename'
label = _('Rename Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_rename.html'
class BulkDelete(ObjectAction):
"""
Delete each of a set of objects.
"""
name = 'bulk_delete'
label = _('Delete Selected')
multi = True
permissions_required = {'delete'}
template_name = 'buttons/bulk_delete.html'

View File

@@ -72,16 +72,6 @@ PREFERENCES = {
),
description=_('The preferred syntax for displaying generic data within the UI')
),
'csv_delimiter': UserPreference(
label=_('CSV delimiter'),
choices=(
('comma', 'Comma (,)'),
('semicolon', 'Semicolon (;)'),
('pipe', 'Pipe (|)'),
),
default='comma',
description=_('The character used to separate fields in CSV data')
),
}

View File

@@ -27,6 +27,7 @@ from utilities.string import trailing_slash
RELEASE = load_release_data()
VERSION = RELEASE.full_version # Retained for backward compatibility
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -124,7 +125,6 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)

View File

@@ -237,7 +237,11 @@ class ActionsColumn(tables.Column):
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
direct button link and icon (default: True)
"""
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
attrs = {
'td': {
'class': 'text-end text-nowrap noprint p-1'
}
}
empty_values = ()
actions = {
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),

View File

@@ -1,8 +1,13 @@
import uuid
from django.test import RequestFactory, TestCase
from django.urls import reverse
from rest_framework.request import Request
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.api.pagination import OptionalLimitOffsetPagination
from utilities.testing import APITestCase
from users.models import Token
class AppTest(APITestCase):
@@ -26,3 +31,40 @@ class AppTest(APITestCase):
response = self.client.get(f'{url}?format=api', **self.header)
self.assertEqual(response.status_code, 200)
class OptionalLimitOffsetPaginationTest(TestCase):
def setUp(self):
self.paginator = OptionalLimitOffsetPagination()
self.factory = RequestFactory()
def _make_drf_request(self, path='/', query_params=None):
"""Helper to create a proper DRF Request object"""
return Request(self.factory.get(path, query_params or {}))
def test_raises_exception_for_unordered_queryset(self):
"""Should raise QuerySetNotOrdered for unordered QuerySet"""
queryset = Token.objects.all().order_by()
request = self._make_drf_request()
with self.assertRaises(QuerySetNotOrdered) as cm:
self.paginator.paginate_queryset(queryset, request)
error_msg = str(cm.exception)
self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
def test_allows_ordered_queryset(self):
"""Should not raise exception for ordered QuerySet"""
queryset = Token.objects.all().order_by('created')
request = self._make_drf_request()
self.paginator.paginate_queryset(queryset, request) # Should not raise exception
def test_allows_non_queryset_iterables(self):
"""Should not raise exception for non-QuerySet iterables"""
iterable = [1, 2, 3, 4, 5]
request = self._make_drf_request()
self.paginator.paginate_queryset(iterable, request) # Should not raise exception

View File

@@ -16,10 +16,6 @@ class TestJobRunner(JobRunner):
def run(self, *args, **kwargs):
if kwargs.get('make_fail', False):
raise JobFailed()
self.logger.debug("Debug message")
self.logger.info("Info message")
self.logger.warning("Warning message")
self.logger.error("Error message")
class JobRunnerTestCase(TestCase):
@@ -55,16 +51,8 @@ class JobRunnerTest(JobRunnerTestCase):
def test_handle(self):
job = TestJobRunner.enqueue(immediate=True)
# Check job status
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
# Check logging
self.assertEqual(len(job.log_entries), 4)
self.assertEqual(job.log_entries[0]['message'], "Debug message")
self.assertEqual(job.log_entries[1]['message'], "Info message")
self.assertEqual(job.log_entries[2]['message'], "Warning message")
self.assertEqual(job.log_entries[3]['message'], "Error message")
def test_handle_failed(self):
with disable_warnings('netbox.jobs'):
job = TestJobRunner.enqueue(immediate=True, make_fail=True)

View File

@@ -15,20 +15,18 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_tables2.export import TableExport
from mptt.models import MPTTModel
from core.models import ObjectType
from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices
from extras.models import CustomField, ExportTemplate
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.export import TableExport
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect
@@ -56,12 +54,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
Attributes:
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
template_name = 'generic/object_list.html'
filterset = None
filterset_form = None
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
@@ -78,7 +76,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
return '---\n'.join(yaml_data)
def export_table(self, table, columns=None, filename=None, delimiter=None):
def export_table(self, table, columns=None, filename=None):
"""
Export all table data in CSV format.
@@ -87,7 +85,6 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
columns: A list of specific columns to include. If None, all columns will be exported.
filename: The name of the file attachment sent to the client. If None, will be determined automatically
from the queryset model name.
delimiter: The character used to separate columns (a comma is used by default)
"""
exclude_columns = {'pk', 'actions'}
if columns:
@@ -98,8 +95,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
exporter = TableExport(
export_format=TableExport.CSV,
table=table,
exclude_columns=exclude_columns,
delimiter=delimiter,
exclude_columns=exclude_columns
)
return exporter.response(
filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
@@ -154,16 +150,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user)
has_table_actions = any(action.multi for action in actions)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
if 'export' in request.GET:
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(self.queryset, request, has_table_actions)
table = self.get_table(self.queryset, request, has_bulk_actions)
columns = [name for name, _ in table.selected_columns]
delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, columns, delimiter=delimiter)
return self.export_table(table, columns)
# Render an ExportTemplate
elif request.GET['export']:
@@ -179,12 +174,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Fall back to default table/YAML export
else:
table = self.get_table(self.queryset, request, has_table_actions)
delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, delimiter=delimiter)
table = self.get_table(self.queryset, request, has_bulk_actions)
return self.export_table(table)
# Render the objects table
table = self.get_table(self.queryset, request, has_table_actions)
table = self.get_table(self.queryset, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
@@ -504,32 +498,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if form.is_valid():
logger.debug("Import form validation was successful")
redirect_url = reverse(get_viewname(model, action='list'))
new_objects = []
# If indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']:
job_name = _('Bulk import {count} {object_type}').format(
count=len(form.cleaned_data['data']),
object_type=model._meta.verbose_name_plural,
)
if job := process_request_as_job(self.__class__, request, name=job_name):
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
url=job.get_absolute_url(),
job=job
)
messages.info(request, mark_safe(msg))
return redirect(redirect_url)
try:
# Iterate through data and bind each record to a new model form instance.
with transaction.atomic(using=router.db_for_write(model)):
new_objects = self.create_and_update_objects(form, request)
new_objs = self.create_and_update_objects(form, request)
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
raise PermissionsViolation
if new_objs:
msg = f"Imported {len(new_objs)} {model._meta.verbose_name_plural}"
logger.info(msg)
messages.success(request, msg)
view_name = get_viewname(model, action='list')
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
return redirect(results_url)
except (AbortTransaction, ValidationError):
clear_events.send(sender=self)
@@ -538,25 +525,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
form.add_error(None, e.message)
clear_events.send(sender=self)
# If this request was executed via a background job, return the raw data for logging
if is_background_request(request):
return AsyncJobData(
log=[
_('Created {object}').format(object=str(obj))
for obj in new_objects
],
errors=form.errors
)
if new_objects:
msg = _("Imported {count} {object_type}").format(
count=len(new_objects),
object_type=model._meta.verbose_name_plural
)
logger.info(msg)
messages.success(request, msg)
return redirect(f"{redirect_url}?modified_by_request={request.id}")
else:
logger.debug("Form validation failed")
@@ -761,11 +729,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
"""
An extendable view for renaming objects in bulk.
Attributes:
field_name: The name of the object attribute for which the value is being updated (defaults to "name")
"""
field_name = 'name'
template_name = 'generic/bulk_rename.html'
def __init__(self, *args, **kwargs):
@@ -795,12 +759,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
try:
obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
obj.new_name = re.sub(find, replace, obj.name or '')
# Catch regex group reference errors
except re.error:
obj.new_name = getattr(obj, self.field_name)
obj.new_name = obj.name
else:
obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks
@@ -819,7 +783,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
if '_apply' in request.POST:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.name = obj.new_name
obj.save()
# Enforce constrained permissions
@@ -849,7 +813,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'field_name': self.field_name,
'form': form,
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,

View File

@@ -1,7 +1,7 @@
from django.shortcuts import get_object_or_404
from extras.models import TableConfig
from netbox import object_actions
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from utilities.permissions import get_permission_for_model
__all__ = (
@@ -9,18 +9,6 @@ __all__ = (
'TableMixin',
)
# TODO: Remove in NetBox v4.5
LEGACY_ACTIONS = {
'add': object_actions.AddObject,
'edit': object_actions.EditObject,
'delete': object_actions.DeleteObject,
'export': object_actions.BulkExport,
'bulk_import': object_actions.BulkImport,
'bulk_edit': object_actions.BulkEdit,
'bulk_rename': object_actions.BulkRename,
'bulk_delete': object_actions.BulkDelete,
}
class ActionsMixin:
"""
@@ -31,24 +19,7 @@ class ActionsMixin:
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
with custom actions, such as bulk_sync.
"""
actions = tuple()
# TODO: Remove in NetBox v4.5
def _convert_legacy_actions(self):
"""
Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses.
"""
if type(self.actions) is not dict:
return
actions = []
for name in self.actions.keys():
try:
actions.append(LEGACY_ACTIONS[name])
except KeyError:
raise ValueError(f"Unsupported legacy action: {name}")
self.actions = actions
actions = DEFAULT_ACTION_PERMISSIONS
def get_permitted_actions(self, user, model=None):
"""
@@ -56,15 +27,11 @@ class ActionsMixin:
"""
model = model or self.queryset.model
# TODO: Remove in NetBox v4.5
# Handle legacy action sets
self._convert_legacy_actions()
# Resolve required permissions for each action
permitted_actions = []
for action in self.actions:
required_permissions = [
get_permission_for_model(model, perm) for perm in action.permissions_required
get_permission_for_model(model, name) for name in self.actions.get(action, set())
]
if not required_permissions or user.has_perms(required_permissions):
permitted_actions.append(action)

View File

@@ -14,9 +14,6 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from core.signals import clear_events
from netbox.object_actions import (
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
)
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields
@@ -39,7 +36,7 @@ __all__ = (
)
class ObjectView(ActionsMixin, BaseObjectView):
class ObjectView(BaseObjectView):
"""
Retrieve a single object for display.
@@ -47,10 +44,8 @@ class ObjectView(ActionsMixin, BaseObjectView):
Attributes:
tab: A ViewTab instance for the view
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
"""
tab = None
actions = (CloneObject, EditObject, DeleteObject)
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
@@ -77,11 +72,9 @@ class ObjectView(ActionsMixin, BaseObjectView):
request: The current request
"""
instance = self.get_object(**kwargs)
actions = self.get_permitted_actions(request.user, model=instance)
return render(request, self.get_template_name(), {
'object': instance,
'actions': actions,
'tab': self.tab,
**self.get_extra_context(request, instance),
})
@@ -97,13 +90,13 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table: The django-tables2 Table class used to render the child objects list
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
child_model = None
table = None
filterset = None
filterset_form = None
actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
@@ -145,10 +138,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model)
has_table_actions = any(action.multi for action in actions)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, has_table_actions)
table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):

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