Compare commits

..

90 Commits

Author SHA1 Message Date
Jeremy Stretch
f845b2cf07 Release v4.2.2 2025-01-17 15:05:09 -05:00
atownson
2ed4a2b005 Fixes: #18369 - Remove the json filter for protection rules (#18388)
* Remove the json filter for protection rules

* Configure PROTECTION_RULE config attribute to use ConfigJSONEncoder as serializer

* Tweak getattr()

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-01-17 14:02:12 -05:00
Jeremy Stretch
5b9210dfa5 Fixes #18392: Exclude config contexts assigned to locations for VMs 2025-01-17 13:14:05 -05:00
Jeremy Stretch
4a13664e0f Closes #18425: Remove the triage priority field from GitHub issue templates 2025-01-17 11:06:17 -05:00
Jeremy Stretch
a9f3c74b0c Fixes #18379: Ensure RSS feed content within dashboard widget is sanitized 2025-01-17 10:25:22 -05:00
Brian Tiemann
50b7f46fc0 Migrate DEFAULT_FILE_STORAGE to STORAGES 2025-01-17 09:04:51 -05:00
Brian Tiemann
07ad4c1321 Make GFK scope field sortable=False on tables where it appears 2025-01-17 08:52:12 -05:00
bctiemann
4a1fea3504 Fixes: #18336 - Perform Rack object validation of u_height and starting_unit on rack_type if present (#18395)
* Perform Rack object validation of u_height and starting_unit on rack_type if present

* Calculate effective values before doing validation
2025-01-17 08:45:17 -05:00
bctiemann
993d8f1480 Fixes: #18373 - Fix validation of site in Assign Device to Cluster flow (#18375)
* Fix validation of site in Assign Device to Cluster flow

* Validate Location as well as Site scope
2025-01-17 08:35:17 -05:00
bctiemann
c3efa2149c Fixes: #18350 - Remove 'site' and 'provider_network' from CircuitTerminationIndex.display_attrs (#18351)
* Remove 'site' and 'provider_network' from CircuitTerminationIndex.display_attrs

* Use '_site' and '_provider_network' in display_attrs

* Replace private fields with 'termination'
2025-01-17 08:28:43 -05:00
Jeremy Stretch
a75fa53d4d Closes #18348: Disable legacy pre-commit hook script 2025-01-13 08:50:34 -05:00
Jeremy Stretch
e75d327f38 Fixes #18376: Include tagged VLANs in interfaces list for Q-in-Q interfaces 2025-01-10 09:10:34 -05:00
github-actions
a79d869bd8 Update source translation strings 2025-01-10 05:02:08 +00:00
Brian Tiemann
32422d1683 Don't cache CACHE_KEY_CATALOG_ERROR if ISOLATED_DEPLOYMENT is True 2025-01-09 15:21:27 -05:00
Jeremy Stretch
571f604ce8 Fixes #18368: Restore missing fields on REST API serializer for MAC addresses 2025-01-09 14:53:03 -05:00
Jeremy Stretch
b12c8c880f Fixes #18363: Fix assignment of MAC addresses to interfaces via REST API (#18367)
* Fixes #18363: Fix assignment of MAC addresses to interfaces via REST API

* Add missing API & view tests
2025-01-09 13:55:19 -05:00
Jeremy Stretch
b11f179527 Closes #18362: Create a system job for census reporting 2025-01-09 11:56:09 -05:00
Brian Tiemann
80e1fd02bb Update docs to indicate PostgreSQL 13+ requirement 2025-01-09 10:58:51 -05:00
github-actions
4090afbf24 Update source translation strings 2025-01-09 05:02:09 +00:00
Jeremy Stretch
d04fc11c61 Release v4.2.1 (#18346)
* Release v4.2.1

* Add changelog for #18282
2025-01-08 10:19:28 -05:00
Brian Tiemann
f6b8c1966d Use order_by to change ordering behavior of VLAN column rather than changing accessor 2025-01-08 09:54:00 -05:00
Brian Tiemann
4456c488f1 Change PrefixTable.vlan to represent the VLAN ID rather than the VLAN object, to enable more useful sorting by VLAN ID rather than site-grouped VLAN objects 2025-01-08 09:54:00 -05:00
Jeremy Stretch
53aa2c8624 Fixes #18329: Pin strawberry-graphql-django to v0.52.0 to resolve upstream bug 2025-01-08 08:59:54 -05:00
github-actions
ffac0974dd Update source translation strings 2025-01-08 05:02:12 +00:00
bctiemann
e518f08604 Fixes: #18316 - Fix PrefixIndex reference to 'site' (#18322)
* Fix PrefixIndex reference to 'site'

* Fix ClusterIndex reference to 'site' and add 'scope' to WirelessLANIndex
2025-01-07 10:47:05 -05:00
Tobias Genannt
4ae5529362 Fix #18314: Use get to avoid KeyError 2025-01-07 10:39:55 -05:00
Jeremy Stretch
ef6c89ee5d Fixes #18324: Correct filter names for certain related object listings 2025-01-07 10:34:35 -05:00
Jeremy Stretch
9c960c2387 Fixes #18318: Correct navigation breadcrumbs for module type UI view 2025-01-07 10:28:22 -05:00
github-actions
ed541220e8 Update source translation strings 2025-01-07 05:02:25 +00:00
Jeremy Stretch
14cec518f5 Closes #18311: Update minimum required version of PostgreSQL 2025-01-06 17:04:13 -05:00
Jeremy Stretch
9d82a668a4 Release v4.2.0 2025-01-06 16:13:24 -05:00
Jeremy Stretch
b7610971c0 Closes #13366: Update documentation for main branch (#18309)
* Closes #13366: Update documentation for main branch

* Clarify wording
2025-01-06 15:29:03 -05:00
Jeremy Stretch
ab0a1f0bbc Merge pull request #18308 from netbox-community/feature
Prep for v4.2.0 release
2025-01-06 14:02:29 -05:00
Jeremy Stretch
5d1070796d Merge branch 'develop' into feature 2025-01-06 13:42:57 -05:00
Jeremy Stretch
83d62315cc Closes #18153: Introduce virtual circuit types (#18300)
* Closes #18153: Introduce virtual circuit types

* Fix TagTestCase

* Fix GraphQL API test
2025-01-06 13:37:43 -05:00
Jeremy Stretch
ab8fc3de5e Merge branch 'master' into develop 2025-01-06 11:25:43 -05:00
Jeremy Stretch
67657efe1c Release v4.1.11 2025-01-06 11:24:29 -05:00
bctiemann
c9ee699633 Fixes: #18263 - Iterate through a freshly queried set of CableTerminations to find endpoints in update_connected_endpoints (#18264)
* Iterate through a freshly queried set of CableTerminations to find endpoints in update_connected_endpoints

* Add defensive break if q_filter has not been populated
2025-01-06 09:54:13 -05:00
Brian Tiemann
89d7487197 Update some detail views with prefetch_related from 'site' to 'scope' 2025-01-06 09:48:14 -05:00
github-actions
40f22533d1 Update source translation strings 2025-01-04 05:02:13 +00:00
Jeremy Stretch
c3b0de3ebd Closes #18281: Support group assignment for virtual circuits (#18291)
* Rename circuit to member on CircuitGroupAssignment

* Support group assignment for virtual circuits

* Update release notes

* Introduce separate nav menu heading for circuit groups

* Add generic relations for group assignments

* Remove obsolete code

* Clean up bulk import & extend tests
2025-01-03 13:42:47 -05:00
bctiemann
e8e3981da5 Fixes: #18289 - Add 'created' and 'last_updated' fields to ModuleTypeTable (#18292)
* Add 'created' and 'last_updated' fields to ModuleTypeTable for consistency

* Add 'created' and 'last_updated' fields to ModuleTable for consistency
2025-01-03 12:35:04 -05:00
Jeremy Stretch
b9abb3200c Fixes #18271: Require only encryption OR authentication algorithm when creating an IPSec proposal via REST API 2025-01-03 12:33:58 -05:00
Jeremy Stretch
10748edc3a Fixes #18222: Include action data from event rule in webhook and custom script data 2025-01-03 09:39:05 -05:00
Jeremy Stretch
6f4bec7644 Fixes #18278: Restore missing columns on MACAddressTable 2024-12-30 14:00:29 -05:00
bctiemann
0cda10a204 Fixes: #18203 - Validate that scope is selected if scope type is specified (#18254)
* Validate that a scope has been selected if a scope_type is specified, on CachedScopeMixin models

* Cleaner logic

* Call super().clean() after validating scope_type/scope
2024-12-30 12:36:46 -05:00
Jeremy Stretch
685264c757 Merge branch 'develop' into feature 2024-12-30 12:30:34 -05:00
Thor Selmer Dreier-Hansen
f03489f58e Add distinct() to filtering VLANs by assigned interface (#18274) 2024-12-27 15:11:51 -05:00
Jeremy Stretch
c6452b33d8 Merge pull request #18267 from netbox-community/develop
Release v4.1.10
2024-12-23 11:42:29 -05:00
Jeremy Stretch
16917133b2 Merge branch 'master' into develop 2024-12-23 11:24:38 -05:00
Jeremy Stretch
28eada13d3 Release v4.1.10 2024-12-23 10:59:52 -05:00
Tobias Genannt
6ddd3cc779 #18260 - Add context managers to registry 2024-12-23 10:27:25 -05:00
bctiemann
1a631dd7cc Merge pull request #18258 from netbox-community/develop
Release v4.1.9
2024-12-18 10:08:23 -05:00
Brian Tiemann
8c07978042 Merge branch 'master' into develop 2024-12-18 09:47:37 -05:00
bctiemann
7e3d8e9c3b Merge pull request #18253 from netbox-community/release-v4.1.9
Release v4.1.9
2024-12-18 09:40:44 -05:00
Jeremy Stretch
e396097f3c Release v4.1.9 2024-12-17 15:59:39 -05:00
bctiemann
8d6cec408c Fixes: #17868 - Handle orphaned cable condition gracefully in SVG rendering (#18244)
* Handle condition gracefully where an empty object list is passed in to draw_far_objects (e.g. orphaned cable where attached device has been deleted)

* Move continue statement to right after draw_far_objects

* Preferable falsy syntax

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Check far_ends rather than altering draw_far_objects

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-17 15:39:29 -05:00
bctiemann
e7fcbffaf3 Fixes: #16757 - Use table_htmx.html for assigning ipaddresses (#18226)
* Use table_htmx.html for assigning ipaddresses

* Add disable_htmx property on ObjectChildrenView to allow IP assignment flow to avoid htmx fragmentary rendering on object save

* Revert "Add disable_htmx property on ObjectChildrenView to allow IP assignment flow to avoid htmx fragmentary rendering on object save"

This reverts commit fa8f2ac377.
2024-12-17 14:46:52 -05:00
Jeremy Stretch
0b9ead3e8b Closes #18224: Apply all registered request processors when running custom scripts 2024-12-16 11:54:42 -05:00
bctiemann
13c26ccb0c Fixes: #18184 - Gracefully handle unavailable internet connection on RSS feed dashboard widget if ISOLATED_DEPLOYMENT is set (#18186)
* Suppress adding the RSS feed widget to the dashboard if ISOLATED_DEPLOYMENT is set

* Add config option on RSSFeedWidget to specify requires_internet and to display a more appropriate error if ISOLATED_DEPLOYMENT is set

* Remove skipping behavior from utils.py

* Add required=False
2024-12-16 11:46:28 -05:00
Jeremy Stretch
aa56b99566 Closes #18045: Enable adding a new MAC to an interface via quick add (#18200)
* Closes #18045: Enable adding a new MAC to an interface via quick add

* Misc cleanup
2024-12-16 10:57:09 -05:00
Brian Tiemann
c0fec28b2a Handle editing IPAddresses on VMInterfaces without parent.oob_ip 2024-12-16 10:17:22 -05:00
Kay Schroeder
382e246b2c Added the cable -> CableType-Annotation in CableTerminationType. 2024-12-16 10:14:16 -05:00
Pieter Lambrecht
fff4ec78ad set disabled interface backgroundcolor to $gray-400 2024-12-16 10:12:15 -05:00
github-actions
8951aa815f Update source translation strings 2024-12-13 05:02:21 +00:00
Jeremy Stretch
39ca3ce571 Merge branch 'develop' into feature 2024-12-12 12:13:45 -05:00
Jeremy Stretch
b89601d93d Merge pull request #18221 from netbox-community/develop
Release v4.1.8
2024-12-12 10:52:47 -05:00
Jeremy Stretch
e63fe23af8 Release v4.1.8 2024-12-12 10:37:21 -05:00
Jeremy Stretch
2da1a754c4 Fixes #18213: Enable searching for ASN ranges by name 2024-12-12 09:03:27 -05:00
bctiemann
abfa28dc56 Fixes: #18150 - Get pagination limit with default 0 (#18151)
* Wait until job1 is scheduled before enqueueing job2

* Clamp limit=0 to default_limit

* Handle unspecified limit explicitly so as to return min(PAGINATE_COUNT, MAX_PAGE_SIZE)

* Revert original min()

* Coerce MAX_PAGE_SIZE to be at least PAGINATE_COUNT

* Raise ImproperlyConfigured error if MAX_PAGE_SIZE < PAGINATE_COUNT

* Revert test behavior

* Revert "Revert test behavior"

This reverts commit 5087a1111a.

* Revert "Raise ImproperlyConfigured error if MAX_PAGE_SIZE < PAGINATE_COUNT"

This reverts commit 5dd93c096d.
2024-12-12 09:00:46 -05:00
Jeremy Stretch
8e427e57ea Closes #18211: Enable dynamic registration of request processors (#18212)
* Closes #18211: Enable dynamic registration of request processors

* Tweak syntax
2024-12-12 08:36:56 -05:00
bctiemann
dbaa9c1ce1 Fixes: #18021 - Clear Swagger/drf-spectacular API cache on startup (#18174)
* Clear Swagger API cache on startup

* Clear entire Redis cache on startup if DEBUG=True
2024-12-12 08:16:28 -05:00
github-actions
bd5e7a8d1a Update source translation strings 2024-12-12 05:02:17 +00:00
Pl0xym0r
a15ff294dd fixes 17465 : add racktype on bulkimport and bulkedit of racks (#18077)
* fixes 17465 add racktype on bulkimport and bulkedit of racks

* Make width & u_height optional when setting rack_type on import

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-11 13:16:10 -05:00
Alexander Haase
26f8c3aae3 Closes 18061: Hide traceback from rendered device config (#18127)
* Hide traceback from rendered device config

When an exception occurs during device configuration rendering, it
usually doesn't contain information about the template being rendered,
but rather the trace of how the template was rendered. Since this could
confuse users and expose internal server information, it is now hidden.

* Improve error message display; replicate changes for VMs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-11 10:28:42 -05:00
bctiemann
cc51e7032b Fixes: #17820 - Store default values from custom fields on newly created module components (#18084)
* Store default values from custom fields on newly created module components

* Invert if/for lines to avoid repetition
2024-12-11 09:14:17 -05:00
bctiemann
0219dd7a70 Fixes: #18192 - Use assigned_object instead of interface in display_attrs (#18199)
* Use assigned_object instead of interface in display_attrs

* Remove mac_address
2024-12-11 08:26:48 -05:00
Jeremy Stretch
edc9852229 Fixes #18194: Always pass POST data to bulk edit form 2024-12-10 16:23:30 -05:00
jchambers2012
001f06cc9a Fixes 18183 - Hide Light/Dark Mode and Login Info from Printed Pages (#18185)
* Fixes Print Render

* Suppress the mobile view when printing
2024-12-10 10:31:45 -05:00
github-actions
4017d0ca35 Update source translation strings 2024-12-10 05:02:13 +00:00
Joel McGuire
21962b3488 fix #17960 by adding 6 more tunnel encap options (#18097)
* fix #17960

* updated post feedback

---------

Co-authored-by: Joel L. McGuire <joel.mcguire@ccr.net>
2024-12-09 15:03:00 -05:00
Pl0xym0r
7a92c20576 Fixes 17889: Add checkbox oob ip for ipaddress form (#18057)
* fixes 17889 : add checkbox oob ip for ipaddress

* Minor cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-09 13:35:58 -05:00
Pl0xym0r
3326a6543c Closes #17071: Add is_oob parameter on bulk_import ipaddress (#17975)
* add is_oob parameter on bulk_import ipaddress

* Tweak wording

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-09 10:58:35 -05:00
Daniel Sheppard
674af4d6bc Fixes: #14044 - Allow regex renaming of unnamed devices (#17212)
* Fixes: #14044 - Allow regex renaming of unnamed devices

* Allow regex renaming of unnamed devices (already allowed actually)
* Catch errors relating to unnamed devices or integrity errors as a result of the rename process

* Move validation to ensure all renames are eligible

* Update to treat null name an empty string
2024-12-09 09:27:41 -05:00
Jeremy Stretch
8c9bb73ff7 Fixes #17810: Disable DRF's native unique constraint checks 2024-12-05 13:35:47 -05:00
Rob Duffy
327ad8cfc9 Fixes #17490: Config Template unable to dynamically include templates (#18106)
* Fixes #17490: Config Template unable to dynamically include templates

* Cast the generator returned by find_referenced_templates() to an iterable to avoid exhausting it on the check for None

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Apply the path__in filter to avoid duplicating code

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Remove extra if None not in referenced_templates

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-04 21:11:12 -05:00
Martin Rødvand
1e845e6b46 Add status to rack elevation device tooltip (#18083)
* Add status to rack elevation device tooltip

* Use get method for status display

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-12-04 20:59:58 -05:00
github-actions
b4265b74f4 Update source translation strings 2024-12-03 14:23:39 +00:00
Jeremy Stretch
954b5e9ddf Use the housekeeping app to update translation sources 2024-12-03 09:18:40 -05:00
Arthur Hanson
d122c334fd 18044 enable alert for plugins in script 2024-12-02 12:23:00 -05:00
164 changed files with 117328 additions and 97615 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.1.7
placeholder: v4.2.2
validations:
required: true
- type: dropdown
@@ -27,19 +27,6 @@ body:
- Other
validations:
required: true
- type: dropdown
attributes:
label: Triage priority
description: >
Issue triage may be prioritized in some cases. Select whichever of the following
conditions applies, if any.
options:
- I volunteer to perform this work (if approved)
- I'm a NetBox Labs customer
- N/A
default: 2
validations:
required: true
- type: textarea
attributes:
label: Proposed functionality

View File

@@ -22,24 +22,11 @@ body:
- Self-hosted
validations:
required: true
- type: dropdown
attributes:
label: Triage priority
description: >
Issue triage may be prioritized in some cases. Select whichever of the following
conditions applies, if any.
options:
- I volunteer to perform this work (if approved)
- I'm a NetBox Labs customer
- N/A
default: 2
validations:
required: true
- type: input
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.1.7
placeholder: v4.2.2
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
url: https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md
about: "Please read through our contributing policy before opening an issue or pull request."
- name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions

View File

@@ -38,7 +38,7 @@ jobs:
issues may receive direct feedback. **Do not** attempt to circumvent this
process by "bumping" the issue; doing so will result in its immediate closure
and you may be barred from participating in any future discussions. Please see
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
our [contributing guide](https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md).
# Pull request parameters
close-pr-message: >

View File

@@ -18,8 +18,17 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing
steps:
- name: Create app token
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: 1076524
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@v5

View File

@@ -1,12 +1,12 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
<img src="https://raw.githubusercontent.com/netbox-community/netbox/main/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
<p><strong>The cornerstone of every automated network</strong></p>
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
<p>
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |

View File

@@ -8,8 +8,6 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
django-debug-toolbar
# Library for writing reusable URL query filters
@@ -101,7 +99,7 @@ netaddr
nh3
# Fork of PIL (Python Imaging Library) for image processing
# https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst
# https://github.com/python-pillow/Pillow/releases
Pillow
# PostgreSQL database adapter for Python
@@ -134,7 +132,8 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# Pinned to v0.52.0 for suspected upstream bug; see #18329
strawberry-graphql-django==0.52.0
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE
NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name
* `USER` - PostgreSQL username

View File

@@ -49,6 +49,10 @@ This key lists all models which have been registered in NetBox which are not des
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
### `request_processors`
A list of context managers to invoke when processing a request e.g. in middleware or when executing a background job. Request processors can be registered with the `@register_request_processor` decorator.
### `search`
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.

View File

@@ -37,16 +37,12 @@ CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scri
### 2. Create a New Branch
The NetBox project utilizes three persistent git branches to track work:
The NetBox project utilizes two persistent git branches to track work:
* `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable (patch) release occurs here
* `feature` - Tracks work on an upcoming minor release
* `main` - All development on the upcoming stable (patch) release occurs here. Releases are published from this branch.
* `feature` - All work planned for the upcoming minor release is done here.
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. For example, assume that the current NetBox release is v3.3.5. Work applied to the `develop` branch will appear in v3.3.6, and work done under the `feature` branch will be included in the next minor release (v3.4.0).
!!! warning
**Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release.
Typically, you'll base pull requests off of the `main` branch, or off of `feature` if you're working on the upcoming minor or major release. For example, assume that the current NetBox release is v4.2.3. Work applied to the `main` branch will appear in v4.2.4, and work done under the `feature` branch will be included in the next minor release (v4.3.0).
To create a new branch, first ensure that you've checked out the desired base branch, then run:

View File

@@ -128,7 +128,7 @@ Fast-forward
```
!!! warning "Avoid Merging Remote Branches"
You generally want to avoid merging branches that exist on the remote (upstream) repository, such as `develop` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally.
You generally want to avoid merging branches that exist on the remote (upstream) repository, namely `main` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally.
### Show Pending Changes
@@ -196,7 +196,7 @@ index 93e125079..4344fb514 100644
+and here too
+
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<img src="https://raw.githubusercontent.com/netbox-community/netbox/main/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div>
diff --git a/foo.py b/foo.py
new file mode 100644

View File

@@ -8,11 +8,10 @@ NetBox and many of its related projects are maintained on [GitHub](https://githu
![GitHub](../media/development/github.png)
There are three permanent branches in the repository:
There are two permanent branches in the repository:
* `master` - The current stable release. Individual changes should never be pushed directly to this branch, but rather merged from `develop`.
* `develop` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release.
* `feature` - New feature work to be introduced in the next minor release (e.g. from v3.3 to v3.4).
* `main` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release.
* `feature` - New feature work to be introduced in the next minor release (e.g. from v4.2 to v4.3).
NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
@@ -57,4 +56,4 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo
## Licensing
The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/master/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license.
The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/main/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license.

View File

@@ -43,9 +43,9 @@ Follow these instructions to perform a new installation of NetBox in a temporary
Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`.
### Merge the Release Branch
### Merge the `feature` Branch
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
### Rebuild Demo Data (After Release)
@@ -55,6 +55,15 @@ After the release of a new minor version, generate a new demo data snapshot comp
## Patch Releases
### Create a Release Branch
Begin by creating a new branch (based off of `main`) to effect the release. This will comprise the changes listed below.
```
git checkout main
git checkout -B release-vX.Y.Z
```
### Notify netbox-docker Project of Any Relevant Changes
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
@@ -111,25 +120,19 @@ Then, compile these portable (`.po`) files for use in the application:
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
* Replace the "FUTURE" placeholder in the release notes with the current date.
Commit these changes to the `develop` branch and push upstream.
### Verify CI Build Status
Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceeding with the release.
### Submit a Pull Request
Submit a pull request titled **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body.
Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body.
Once CI has completed on the PR, merge it. This effects a new release in the `master` branch.
Once CI has completed and a colleague has reviewed the PR, merge it. This effects a new release in the `main` branch.
### Create a New Release
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
* **Tag:** Current version (e.g. `v3.3.1`)
* **Target:** `master`
* **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`)
* **Tag:** Current version (e.g. `v4.2.1`)
* **Target:** `main`
* **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
Once created, the release will become available for users to install.

View File

@@ -14,10 +14,10 @@ To update the English `.po` file from which all translations are derived, use th
./manage.py makemessages -l en -i "project-static/*"
```
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
Then, commit the change and push to the `main` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
!!! note
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml).
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/main/.github/workflows/update-translation-strings.yml).
## Updating Translated Strings

View File

@@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning "PostgreSQL 12 or later required"
NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
!!! warning "PostgreSQL 13 or later required"
NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable --now postgresql
```
Before continuing, verify that you have installed PostgreSQL 12 or later:
Before continuing, verify that you have installed PostgreSQL 13 or later:
```no-highlight
psql -V

View File

@@ -29,7 +29,7 @@ python3 -V
## Download NetBox
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by checking out the latest release tag.
### Option A: Download a Release Archive
@@ -67,16 +67,13 @@ If `git` is not already installed, install it:
sudo yum install -y git
```
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
Next, clone the git repository:
```no-highlight
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
sudo git clone https://github.com/netbox-community/netbox.git .
```
!!! note
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
The `git clone` command should generate output similar to the following:
This command should generate output similar to the following:
```
Cloning into '.'...
@@ -88,8 +85,13 @@ Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (148/148), done.
```
!!! note
Installation via git also allows you to easily try out different versions of NetBox. To check out a [specific NetBox release](https://github.com/netbox-community/netbox/releases), use the `git checkout` command with the desired release tag. For example, `git checkout v3.0.8`.
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
```
sudo git checkout vX.Y.Z
```
Using this installation method enables easy upgrades in the future by simply checking out the latest release tag.
## Create the NetBox System User

View File

@@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
| PostgreSQL | 12+ |
| PostgreSQL | 13+ |
| Redis | 4.0+ |
Below is a simplified overview of the NetBox application stack for reference:

View File

@@ -20,15 +20,15 @@ NetBox requires the following dependencies:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
| PostgreSQL | 12+ |
| PostgreSQL | 13+ |
| Redis | 4.0+ |
## 3. Install the Latest Release
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
!!! warning
Use the same method as you used to install NetBox originally
Use the same method as you used to install NetBox originally.
If you are not sure how NetBox was installed originally, check with this command:
@@ -36,10 +36,7 @@ If you are not sure how NetBox was installed originally, check with this command
ls -ld /opt/netbox /opt/netbox/.git
```
If NetBox was installed from a release package, then `/opt/netbox` will be a
symlink pointing to the current version, and `/opt/netbox/.git` will not
exist. If it was installed from git, then `/opt/netbox` and
`/opt/netbox/.git` will both exist as normal directories.
If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories.
### Option A: Download a Release
@@ -84,20 +81,20 @@ If you followed the original installation guide to set up gunicorn, be sure to c
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
```
### Option B: Clone the Git Repository
### Option B: Check Out a Git Release
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
```no-highlight
cd /opt/netbox
sudo git checkout master
sudo git pull origin master
```
sudo git fetch --tags
git describe --tags $(git rev-list --tags --max-count=1)
```
!!! info "Checking out an older release"
If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do:
Check out the desired release by specifying its tag:
sudo git checkout v2.11.11
```
sudo git checkout v4.2.0
```
## 4. Run the Upgrade Script

View File

@@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL 12+ |
| Database | PostgreSQL 13+ |
| Task queuing | Redis/django-rq |

View File

@@ -8,9 +8,9 @@ Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation
The [circuit group](./circuitgroup.md) being assigned.
### Circuit
### Member
The [circuit](./circuit.md) that is being assigned to the group.
The [circuit](./circuit.md) or [virtual circuit](./virtualcircuit.md) assigned to the group.
### Priority

View File

@@ -18,6 +18,10 @@ The [provider account](./provideraccount.md) with which the virtual circuit is a
The unique identifier assigned to the virtual circuit by its [provider](./provider.md).
### Type
The assigned [virtual circuit type](./virtualcircuittype.md).
### Status
The operational status of the virtual circuit. By default, the following statuses are available:

View File

@@ -0,0 +1,13 @@
# Virtual Circuit Types
Like physical [circuits](./circuit.md), [virtual circuits](./virtualcircuit.md) are classified by functional type. These types are completely customizable, and can help categorize circuits by function or technology.
## Fields
### Name
A unique human-friendly name.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)

View File

@@ -10,7 +10,7 @@ See the [event rules documentation](../../features/event-rules.md) for more inf
A unique human-friendly name.
### Content Types
### Object Types
The type(s) of object in NetBox that will trigger the rule.
@@ -38,3 +38,15 @@ The event types which will trigger the rule. At least one event type must be sel
### Conditions
A set of [prescribed conditions](../../reference/conditions.md) against which the triggering object will be evaluated. If the conditions are defined but not met by the object, no action will be taken. An event rule that does not define any conditions will _always_ trigger.
### Action Type
The type of action to take when the rule triggers. This must be one of the following choices:
* Webhook
* Custom script
* Notification
### Action Data
An optional dictionary of JSON data to pass when executing the rule. This can be useful to include additional context data, e.g. when transmitting a webhook.

View File

@@ -1,6 +1,71 @@
# NetBox v4.1
## v4.1.7 (FUTURE)
## v4.1.11 (2025-01-06)
### Bug Fixes
* [#17771](https://github.com/netbox-community/netbox/issues/17771) - Fix duplicate entries appearing on VLAN list when filtering by interface assignment
* [#18222](https://github.com/netbox-community/netbox/issues/18222) - Pass event rule action data to webhooks as context data
* [#18263](https://github.com/netbox-community/netbox/issues/18263) - Fix recalculation of cable paths when modifying cable terminations via the REST API
* [#18271](https://github.com/netbox-community/netbox/issues/18271) - Require only encryption _or_ authentication algorithm when creating an IPSec proposal via the REST API
* [#18289](https://github.com/netbox-community/netbox/issues/18289) - Enable ordering modules and module types by created & last updated times
---
## v4.1.10 (2024-12-23)
### Bug Fixes
* [#18260](https://github.com/netbox-community/netbox/issues/18260) - Fix object change logging
---
## v4.1.9 (2024-12-17)
!!! danger "Do Not Use"
This release contains a regression which breaks change logging. Please use release v4.1.10 instead.
### Enhancements
* [#17215](https://github.com/netbox-community/netbox/issues/17215) - Change the highlighted color of disabled interfaces in interface lists
* [#18224](https://github.com/netbox-community/netbox/issues/18224) - Apply all registered request processors when running custom scripts
### Bug Fixes
* [#16757](https://github.com/netbox-community/netbox/issues/16757) - Fix rendering of IP addresses table when assigning an existing IP address to an interface with global HTMX navigation enabled
* [#17868](https://github.com/netbox-community/netbox/issues/17868) - Fix `ZeroDivisionError` exception under specific circumstances when generating a cable trace
* [#18124](https://github.com/netbox-community/netbox/issues/18124) - Enable referencing cable attributes when querying a `cabletermination_set` via the GraphQL API
* [#18230](https://github.com/netbox-community/netbox/issues/18230) - Fix `AttributeError` exception when attempting to edit an IP address assigned to a virtual machine interface
---
## v4.1.8 (2024-12-12)
### Enhancements
* [#17071](https://github.com/netbox-community/netbox/issues/17071) - Enable OOB IP address designation during bulk import
* [#17465](https://github.com/netbox-community/netbox/issues/17465) - Enable designation of rack type during bulk import & bulk edit
* [#17889](https://github.com/netbox-community/netbox/issues/17889) - Enable designating an IP address as out-of-band for a device upon creation
* [#17960](https://github.com/netbox-community/netbox/issues/17960) - Add L2TP, PPTP, Wireguard, and OpenVPN tunnel types
* [#18021](https://github.com/netbox-community/netbox/issues/18021) - Automatically clear cache on restart when `DEBUG` is enabled
* [#18061](https://github.com/netbox-community/netbox/issues/18061) - Omit stack trace from rendered device/VM configuration when an exception is raised
* [#18065](https://github.com/netbox-community/netbox/issues/18065) - Include status in device details when hovering on rack elevation
* [#18211](https://github.com/netbox-community/netbox/issues/18211) - Enable the dynamic registration of context managers for request processing
### Bug Fixes
* [#14044](https://github.com/netbox-community/netbox/issues/14044) - Fix unhandled AttributeError exception when bulk renaming objects
* [#17490](https://github.com/netbox-community/netbox/issues/17490) - Fix dynamic inclusion support for config templates
* [#17810](https://github.com/netbox-community/netbox/issues/17810) - Fix validation of racked device fields when modifying via REST API
* [#17820](https://github.com/netbox-community/netbox/issues/17820) - Ensure default custom field values are populated when creating new modules
* [#18044](https://github.com/netbox-community/netbox/issues/18044) - Show plugin-generated alerts within UI views for custom scripts
* [#18150](https://github.com/netbox-community/netbox/issues/18150) - Fix REST API pagination for low `MAX_PAGE_SIZE` values
* [#18183](https://github.com/netbox-community/netbox/issues/18183) - Omit UI navigation bar when printing
* [#18213](https://github.com/netbox-community/netbox/issues/18213) - Fix searching for ASN ranges by name
---
## v4.1.7 (2024-11-21)
### Enhancements

View File

@@ -1,18 +1,50 @@
# NetBox v4.2
## v4.2-beta1 (2024-12-02)
## v4.2.2 (2025-01-17)
!!! danger "Not for Production Use"
This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases.
### Bug Fixes
* [#18336](https://github.com/netbox-community/netbox/issues/18336) - Validate new rack height against installed devices when changing a rack's type
* [#18350](https://github.com/netbox-community/netbox/issues/18350) - Fix `FieldDoesNotExist` exception when global search results include a circuit termination
* [#18353](https://github.com/netbox-community/netbox/issues/18353) - Disable fetching of plugin catalog data when `ISOLATED_DEPLOYMENT` is enabled
* [#18362](https://github.com/netbox-community/netbox/issues/18362) - Avoid transmitting census data on every worker restart
* [#18363](https://github.com/netbox-community/netbox/issues/18363) - Fix support for assigning a MAC address to an interface via the REST API
* [#18368](https://github.com/netbox-community/netbox/issues/18368) - Restore missing attributes from REST API serializer for MAC addresses (`tags`, `created`, `last_updated`, and custom fields)
* [#18369](https://github.com/netbox-community/netbox/issues/18369) - Fix `TypeError` exception when rendering the system configuration view with one or more custom classes defined under `PROTECTION_RULES`
* [#18373](https://github.com/netbox-community/netbox/issues/18373) - Fix `AttributeError` exception when attempting to assign host devices to a cluster
* [#18376](https://github.com/netbox-community/netbox/issues/18376) - Fix the display of tagged VLANs in interfaces list for Q-in-Q interfaces
* [#18379](https://github.com/netbox-community/netbox/issues/18379) - Ensure RSS feed dashboard widget content is sanitized
* [#18392](https://github.com/netbox-community/netbox/issues/18392) - Virtual machines should not inherit config contexts assigned to locations
* [#18400](https://github.com/netbox-community/netbox/issues/18400) - Fix support for `STORAGE_BACKEND` configuration parameter
* [#18406](https://github.com/netbox-community/netbox/issues/18406) - Scope column headers in object lists should not be orderable
---
## v4.2.1 (2025-01-08)
### Bug Fixes
* [#18282](https://github.com/netbox-community/netbox/issues/18282) - Fix ordering of prefixes list by assigned VLAN
* [#18314](https://github.com/netbox-community/netbox/issues/18314) - Fix KeyError exception when rendering pre-saved dashboard (`requires_internet` missing)
* [#18316](https://github.com/netbox-community/netbox/issues/18316) - Fix AttributeError exception when global search results include prefixes and/or clusters
* [#18318](https://github.com/netbox-community/netbox/issues/18318) - Correct navigation breadcrumbs for module type UI view
* [#18324](https://github.com/netbox-community/netbox/issues/18324) - Correct filtering for certain related object listings
* [#18329](https://github.com/netbox-community/netbox/issues/18329) - Address upstream bug in GraphQL API where only one primary IP address is returned within a device/VM list
---
## v4.2.0 (2025-01-06)
### Breaking Changes
* Support for the Django admin UI has been completely removed. (The Django admin UI was disabled by default in NetBox v4.0.)
* This release drops support for PostgreSQL 12. PostgreSQL 13 or later is required to run this release.
* NetBox has adopted collation-based natural ordering for many models. This may alter the order in which some objects are listed by default.
* Automatic redirects from pre-v4.1 UI views for virtual disks have been removed.
* The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key.
* The `site` foreign key field on `ipam.Prefix` has been replaced by the `scope` generic foreign key.
* The `site` foreign key field on `virtualization.Cluster` has been replaced by the `scope` generic foreign key.
* The `circuit` foreign key field on `circuits.CircuitGroupAssignment` has been replaced by the `member` generic foreign key.
* Obsolete nested REST API serializers have been removed. These were deprecated in NetBox v4.1 under [#17143](https://github.com/netbox-community/netbox/issues/17143).
### New Features
@@ -77,6 +109,8 @@ NetBox now supports the designation of customer VLANs (CVLANs) and service VLANs
* `/api/ipam/vlan-translation-rules/`
* circuits.Circuit
* Added the optional `distance` and `distance_unit` fields
* circuits.CircuitGroupAssignment
* Replaced the `circuit` field with `member_type` and `member_id` to support virtual circuit assignment
* circuits.CircuitTermination
* Removed the `site` & `provider_network` fields
* Added the `termination_type` & `termination_id` fields to facilitate termination assignment

View File

@@ -176,6 +176,7 @@ nav:
- Provider Network: 'models/circuits/providernetwork.md'
- Virtual Circuit: 'models/circuits/virtualcircuit.md'
- Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md'
- Virtual Circuit Type: 'models/circuits/virtualcircuittype.md'
- Core:
- DataFile: 'models/core/datafile.md'
- DataSource: 'models/core/datasource.md'

View File

@@ -3,10 +3,10 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import (
Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
VirtualCircuitTermination,
VirtualCircuitTermination, VirtualCircuitType,
)
from dcim.api.serializers_.device_components import InterfaceSerializer
from dcim.api.serializers_.cables import CabledObjectSerializer
@@ -15,7 +15,6 @@ from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializ
from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
__all__ = (
@@ -26,6 +25,7 @@ __all__ = (
'CircuitTypeSerializer',
'VirtualCircuitSerializer',
'VirtualCircuitTerminationSerializer',
'VirtualCircuitTypeSerializer',
)
@@ -154,27 +154,54 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
circuit = CircuitSerializer(nested=True)
member_type = ContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
)
member = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority', 'tags',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_member(self, obj):
if obj.member_id is None:
return None
serializer = get_serializer_for_model(obj.member)
context = {'request': self.context['request']}
return serializer(obj.member, nested=True, context=context).data
class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
# Related object counts
virtual_circuit_count = RelatedObjectCountField('virtual_circuits')
class Meta:
model = VirtualCircuitType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'virtual_circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
class VirtualCircuitSerializer(NetBoxModelSerializer):
provider_network = ProviderNetworkSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
type = VirtualCircuitTypeSerializer(nested=True)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
class Meta:
model = VirtualCircuit
fields = [
'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'status', 'tenant',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'type', 'status',
'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')

View File

@@ -19,6 +19,7 @@ router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet
# Virtual circuits
router.register('virtual-circuits', views.VirtualCircuitViewSet)
router.register('virtual-circuit-types', views.VirtualCircuitTypeViewSet)
router.register('virtual-circuit-terminations', views.VirtualCircuitTerminationViewSet)
app_name = 'circuits-api'

View File

@@ -95,6 +95,16 @@ class ProviderNetworkViewSet(NetBoxModelViewSet):
filterset_class = filtersets.ProviderNetworkFilterSet
#
# Virtual circuit types
#
class VirtualCircuitTypeViewSet(NetBoxModelViewSet):
queryset = VirtualCircuitType.objects.all()
serializer_class = serializers.VirtualCircuitTypeSerializer
filterset_class = filtersets.VirtualCircuitTypeFilterSet
#
# Virtual circuits
#

View File

@@ -1,4 +1,12 @@
from django.db.models import Q
# models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork',
)
CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS = Q(
app_label='circuits',
model__in=['circuit', 'virtualcircuit']
)

View File

@@ -1,4 +1,5 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
@@ -7,7 +8,9 @@ from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from .choices import *
from .models import *
@@ -22,6 +25,7 @@ __all__ = (
'ProviderFilterSet',
'VirtualCircuitFilterSet',
'VirtualCircuitTerminationFilterSet',
'VirtualCircuitTypeFilterSet',
)
@@ -365,26 +369,36 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
method='search',
label=_('Search'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
member_type = ContentTypeFilter()
circuit = MultiValueCharFilter(
method='filter_circuit',
field_name='cid',
label=_('Circuit (CID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
circuit_id = MultiValueNumberFilter(
method='filter_circuit',
field_name='pk',
label=_('Circuit (ID)'),
)
circuit = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__cid',
queryset=Circuit.objects.all(),
to_field_name='cid',
label=_('Circuit (CID)'),
virtual_circuit = MultiValueCharFilter(
method='filter_virtual_circuit',
field_name='cid',
label=_('Virtual circuit (CID)'),
)
virtual_circuit_id = MultiValueNumberFilter(
method='filter_virtual_circuit',
field_name='pk',
label=_('Virtual circuit (ID)'),
)
provider = MultiValueCharFilter(
method='filter_provider',
field_name='slug',
label=_('Provider (name)'),
)
provider_id = MultiValueNumberFilter(
method='filter_provider',
field_name='pk',
label=_('Provider (ID)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(),
@@ -399,16 +413,62 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
class Meta:
model = CircuitGroupAssignment
fields = ('id', 'priority')
fields = ('id', 'member_id', 'priority')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(circuit__cid__icontains=value) |
Q(member__cid__icontains=value) |
Q(group__name__icontains=value)
)
def filter_circuit(self, queryset, name, value):
circuits = Circuit.objects.filter(**{f'{name}__in': value})
if not circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
)
)
def filter_virtual_circuit(self, queryset, name, value):
virtual_circuits = VirtualCircuit.objects.filter(**{f'{name}__in': value})
if not virtual_circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)
def filter_provider(self, queryset, name, value):
providers = Provider.objects.filter(**{f'{name}__in': value})
if not providers.exists():
return queryset.none()
circuits = Circuit.objects.filter(provider__in=providers)
virtual_circuits = VirtualCircuit.objects.filter(provider_network__provider__in=providers)
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
) |
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta:
model = VirtualCircuitType
fields = ('id', 'name', 'slug', 'color', 'description')
class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -437,6 +497,16 @@ class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=ProviderNetwork.objects.all(),
label=_('Provider network (ID)'),
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuitType.objects.all(),
label=_('Virtual circuit type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=VirtualCircuitType.objects.all(),
to_field_name='slug',
label=_('Virtual circuit type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices,
null_value=None

View File

@@ -32,6 +32,7 @@ __all__ = (
'ProviderNetworkBulkEditForm',
'VirtualCircuitBulkEditForm',
'VirtualCircuitTerminationBulkEditForm',
'VirtualCircuitTypeBulkEditForm',
)
@@ -279,7 +280,7 @@ class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
circuit = DynamicModelChoiceField(
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
required=False
@@ -292,11 +293,29 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
model = CircuitGroupAssignment
fieldsets = (
FieldSet('circuit', 'priority'),
FieldSet('member', 'priority'),
)
nullable_fields = ('priority',)
class VirtualCircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
color = ColorField(
label=_('Color'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = VirtualCircuitType
fieldsets = (
FieldSet('color', 'description'),
)
nullable_fields = ('color', 'description')
class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
@@ -308,6 +327,11 @@ class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
queryset=ProviderAccount.objects.all(),
required=False
)
type = DynamicModelChoiceField(
label=_('Type'),
queryset=VirtualCircuitType.objects.all(),
required=False
)
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices),

View File

@@ -24,6 +24,7 @@ __all__ = (
'VirtualCircuitImportForm',
'VirtualCircuitTerminationImportForm',
'VirtualCircuitTerminationImportRelatedForm',
'VirtualCircuitTypeImportForm',
)
@@ -179,10 +180,27 @@ class CircuitGroupImportForm(NetBoxModelImportForm):
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
member_type = CSVContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
label=_('Circuit type (app & model)')
)
priority = CSVChoiceField(
label=_('Priority'),
choices=CircuitPriorityChoices,
required=False
)
class Meta:
model = CircuitGroupAssignment
fields = ('circuit', 'group', 'priority')
fields = ('member_type', 'member_id', 'group', 'priority')
class VirtualCircuitTypeImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = VirtualCircuitType
fields = ('name', 'slug', 'color', 'description', 'tags')
class VirtualCircuitImportForm(NetBoxModelImportForm):
@@ -199,6 +217,12 @@ class VirtualCircuitImportForm(NetBoxModelImportForm):
help_text=_('Assigned provider account (if any)'),
required=False
)
type = CSVModelChoiceField(
label=_('Type'),
queryset=VirtualCircuitType.objects.all(),
to_field_name='name',
help_text=_('Type of virtual circuit')
)
status = CSVChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
@@ -215,7 +239,8 @@ class VirtualCircuitImportForm(NetBoxModelImportForm):
class Meta:
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'status', 'tenant', 'description', 'comments', 'tags',
'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'comments',
'tags',
]

View File

@@ -27,6 +27,7 @@ __all__ = (
'ProviderNetworkFilterForm',
'VirtualCircuitFilterForm',
'VirtualCircuitTerminationFilterForm',
'VirtualCircuitTypeFilterForm',
)
@@ -277,14 +278,14 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
model = CircuitGroupAssignment
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'circuit_id', 'group_id', 'priority', name=_('Assignment')),
FieldSet('provider_id', 'member_id', 'group_id', 'priority', name=_('Assignment')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
circuit_id = DynamicModelMultipleChoiceField(
member_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
@@ -302,12 +303,26 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = VirtualCircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
)
tag = TagFilterField(model)
color = ColorField(
label=_('Color'),
required=False
)
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = VirtualCircuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('status', name=_('Attributes')),
FieldSet('type', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
@@ -332,6 +347,11 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
},
label=_('Provider network')
)
type_id = DynamicModelMultipleChoiceField(
queryset=VirtualCircuitType.objects.all(),
required=False,
label=_('Type')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,

View File

@@ -31,6 +31,7 @@ __all__ = (
'ProviderNetworkForm',
'VirtualCircuitForm',
'VirtualCircuitTerminationForm',
'VirtualCircuitTypeForm',
)
@@ -251,16 +252,71 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
label=_('Group'),
queryset=CircuitGroup.objects.all(),
)
circuit = DynamicModelChoiceField(
member_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
widget=HTMXSelect(),
required=False,
label=_('Circuit type')
)
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
queryset=Circuit.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
fieldsets = (
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
)
class Meta:
model = CircuitGroupAssignment
fields = [
'group', 'circuit', 'priority', 'tags',
'group', 'member_type', 'priority', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.member:
initial['member'] = instance.member
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if member_type_id := get_field_value(self, 'member_type'):
try:
model = ContentType.objects.get(pk=member_type_id).model_class()
self.fields['member'].queryset = model.objects.all()
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
self.fields['member'].disabled = False
self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance.pk and member_type_id != self.instance.member_type_id:
self.initial['member'] = None
def clean(self):
super().clean()
# Assign the selected circuit (if any)
self.instance.member = self.cleaned_data.get('member')
class VirtualCircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'tags'),
)
class Meta:
model = VirtualCircuitType
fields = [
'name', 'slug', 'color', 'description', 'tags',
]
@@ -275,11 +331,16 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
queryset=ProviderAccount.objects.all(),
required=False
)
type = DynamicModelChoiceField(
queryset=VirtualCircuitType.objects.all(),
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet(
'provider_network', 'provider_account', 'cid', 'status', 'description', 'tags', name=_('Virtual circuit'),
'provider_network', 'provider_account', 'cid', 'type', 'status', 'description', 'tags',
name=_('Virtual circuit'),
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@@ -287,7 +348,7 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'status', 'description', 'tenant_group', 'tenant',
'cid', 'provider_network', 'provider_account', 'type', 'status', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
]

View File

@@ -14,6 +14,7 @@ __all__ = (
'ProviderNetworkFilter',
'VirtualCircuitFilter',
'VirtualCircuitTerminationFilter',
'VirtualCircuitTypeFilter',
)
@@ -65,6 +66,12 @@ class ProviderNetworkFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet)
class VirtualCircuitTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuit, lookups=True)
@autotype_decorator(filtersets.VirtualCircuitFilterSet)
class VirtualCircuitFilter(BaseFilterMixin):

View File

@@ -37,3 +37,6 @@ class CircuitsQuery:
virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()

View File

@@ -21,6 +21,7 @@ __all__ = (
'ProviderNetworkType',
'VirtualCircuitTerminationType',
'VirtualCircuitType',
'VirtualCircuitTypeType',
)
@@ -116,12 +117,29 @@ class CircuitGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.CircuitGroupAssignment,
fields='__all__',
exclude=('member_type', 'member_id'),
filters=CircuitGroupAssignmentFilter
)
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
@strawberry_django.field
def member(self) -> Annotated[Union[
Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')],
Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')],
], strawberry.union("CircuitGroupAssignmentMemberType")] | None:
return self.member
@strawberry_django.type(
models.VirtualCircuitType,
fields='__all__',
filters=VirtualCircuitTypeFilter
)
class VirtualCircuitTypeType(OrganizationalObjectType):
color: str
virtual_circuits: List[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@@ -148,6 +166,9 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
class VirtualCircuitType(NetBoxObjectType):
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
provider_account: ProviderAccountType | None
type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
select_related=["type"]
)
tenant: TenantType | None
terminations: List[VirtualCircuitTerminationType]

View File

@@ -2,6 +2,7 @@ import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import utilities.fields
import utilities.json
@@ -14,6 +15,29 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='VirtualCircuitType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder
)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('color', utilities.fields.ColorField(blank=True, max_length=6)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'virtual circuit type',
'verbose_name_plural': 'virtual circuit types',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='VirtualCircuit',
fields=[
@@ -47,6 +71,14 @@ class Migration(migrations.Migration):
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
(
'type',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='virtual_circuits',
to='circuits.virtualcircuittype'
)
),
(
'tenant',
models.ForeignKey(

View File

@@ -0,0 +1,85 @@
import django.db.models.deletion
from django.db import migrations, models
def set_member_type(apps, schema_editor):
"""
Set member_type on any existing CircuitGroupAssignments to the content type for Circuit.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
CircuitGroupAssignment.objects.update(
member_type=ContentType.objects.get_for_model(Circuit)
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0050_virtual_circuits'),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.RemoveConstraint(
model_name='circuitgroupassignment',
name='circuits_circuitgroupassignment_unique_circuit_group',
),
migrations.AlterModelOptions(
name='circuitgroupassignment',
options={'ordering': ('group', 'member_type', 'member_id', 'priority', 'pk')},
),
# Change member_id to an integer field for the member GFK
migrations.RenameField(
model_name='circuitgroupassignment',
old_name='circuit',
new_name='member_id',
),
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_id',
field=models.PositiveBigIntegerField(),
),
# Add content type pointer for the member GFK
migrations.AddField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
related_name='+',
to='contenttypes.contenttype',
blank=True,
null=True
),
preserve_default=False,
),
# Populate member_type for any existing assignments
migrations.RunPython(code=set_member_type, reverse_code=migrations.RunPython.noop),
# Disallow null values for member_type
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
),
),
migrations.AddConstraint(
model_name='circuitgroupassignment',
constraint=models.UniqueConstraint(
fields=('member_type', 'member_id', 'group'),
name='circuits_circuitgroupassignment_unique_member_group'
),
),
]

View File

@@ -0,0 +1,23 @@
from django.utils.translation import gettext_lazy as _
from netbox.models import OrganizationalModel
from utilities.fields import ColorField
__all__ = (
'BaseCircuitType',
)
class BaseCircuitType(OrganizationalModel):
"""
Abstract base model to represent a type of physical or virtual circuit.
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
color = ColorField(
verbose_name=_('color'),
blank=True
)
class Meta:
abstract = True

View File

@@ -1,8 +1,7 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -14,7 +13,7 @@ from netbox.models.mixins import DistanceMixin
from netbox.models.features import (
ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin,
)
from utilities.fields import ColorField
from .base import BaseCircuitType
__all__ = (
'Circuit',
@@ -25,16 +24,11 @@ __all__ = (
)
class CircuitType(OrganizationalModel):
class CircuitType(BaseCircuitType):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
color = ColorField(
verbose_name=_('color'),
blank=True
)
class Meta:
ordering = ('name',)
verbose_name = _('circuit type')
@@ -65,7 +59,7 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
null=True
)
type = models.ForeignKey(
to='CircuitType',
to='circuits.CircuitType',
on_delete=models.PROTECT,
related_name='circuits'
)
@@ -117,6 +111,13 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
null=True
)
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='circuit'
)
clone_fields = (
'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description',
@@ -177,15 +178,23 @@ class CircuitGroup(OrganizationalModel):
class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
"""
Assignment of a Circuit to a CircuitGroup with an optional priority.
Assignment of a physical or virtual circuit to a CircuitGroup with an optional priority.
"""
circuit = models.ForeignKey(
Circuit,
on_delete=models.CASCADE,
related_name='assignments'
member_type = models.ForeignKey(
to='contenttypes.ContentType',
limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
member_id = models.PositiveBigIntegerField(
verbose_name=_('member ID')
)
member = GenericForeignKey(
ct_field='member_type',
fk_field='member_id'
)
group = models.ForeignKey(
CircuitGroup,
to='circuits.CircuitGroup',
on_delete=models.CASCADE,
related_name='assignments'
)
@@ -197,16 +206,15 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
null=True
)
prerequisite_models = (
'circuits.Circuit',
'circuits.CircuitGroup',
)
class Meta:
ordering = ('group', 'circuit', 'priority', 'pk')
ordering = ('group', 'member_type', 'member_id', 'priority', 'pk')
constraints = (
models.UniqueConstraint(
fields=('circuit', 'group'),
name='%(app_label)s_%(class)s_unique_circuit_group'
fields=('member_type', 'member_id', 'group'),
name='%(app_label)s_%(class)s_unique_member_group'
),
)
verbose_name = _('Circuit group assignment')

View File

@@ -1,5 +1,6 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -8,13 +9,26 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
from .base import BaseCircuitType
__all__ = (
'VirtualCircuit',
'VirtualCircuitTermination',
'VirtualCircuitType',
)
class VirtualCircuitType(BaseCircuitType):
"""
Like physical circuits, virtual circuits can be organized by their functional role. For example, a user might wish
to categorize virtual circuits by their technological nature or by product name.
"""
class Meta:
ordering = ('name',)
verbose_name = _('virtual circuit type')
verbose_name_plural = _('virtual circuit types')
class VirtualCircuit(PrimaryModel):
"""
A virtual connection between two or more endpoints, delivered across one or more physical circuits.
@@ -36,6 +50,11 @@ class VirtualCircuit(PrimaryModel):
blank=True,
null=True
)
type = models.ForeignKey(
to='circuits.VirtualCircuitType',
on_delete=models.PROTECT,
related_name='virtual_circuits'
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
@@ -50,11 +69,19 @@ class VirtualCircuit(PrimaryModel):
null=True
)
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='virtual_circuit'
)
clone_fields = (
'provider_network', 'provider_account', 'status', 'tenant', 'description',
)
prerequisite_models = (
'circuits.ProviderNetwork',
'circuits.VirtualCircuitType',
)
class Meta:

View File

@@ -34,7 +34,7 @@ class CircuitTerminationIndex(SearchIndex):
('port_speed', 2000),
('upstream_speed', 2000),
)
display_attrs = ('circuit', 'site', 'provider_network', 'description')
display_attrs = ('circuit', 'termination', 'description')
@register_search
@@ -100,3 +100,14 @@ class VirtualCircuitTerminationIndex(SearchIndex):
('description', 500),
)
display_attrs = ('virtual_circuit', 'role', 'description')
@register_search
class VirtualCircuitTypeIndex(SearchIndex):
model = models.VirtualCircuitType
fields = (
('name', 100),
('slug', 110),
('description', 500),
)
display_attrs = ('description',)

View File

@@ -45,7 +45,7 @@ class CircuitTypeTable(NetBoxTable):
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
@@ -61,6 +61,10 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_a = columns.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
@@ -188,11 +192,14 @@ class CircuitGroupAssignmentTable(NetBoxTable):
linkify=True
)
provider = tables.Column(
accessor='circuit__provider',
accessor='member__provider',
verbose_name=_('Provider'),
linkify=True
)
circuit = tables.Column(
member_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
member = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
@@ -206,6 +213,7 @@ class CircuitGroupAssignmentTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CircuitGroupAssignment
fields = (
'pk', 'id', 'group', 'provider', 'circuit', 'priority', 'created', 'last_updated', 'actions', 'tags',
'pk', 'id', 'group', 'provider', 'member_type', 'member', 'priority', 'created', 'last_updated', 'actions',
'tags',
)
default_columns = ('pk', 'group', 'provider', 'circuit', 'priority')
default_columns = ('pk', 'group', 'provider', 'member_type', 'member', 'priority')

View File

@@ -8,9 +8,34 @@ from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
__all__ = (
'VirtualCircuitTable',
'VirtualCircuitTerminationTable',
'VirtualCircuitTypeTable',
)
class VirtualCircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True,
verbose_name=_('Name'),
)
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='circuits:virtualcircuittype_list'
)
virtual_circuit_count = columns.LinkedCountColumn(
viewname='circuits:virtualcircuit_list',
url_params={'type_id': 'pk'},
verbose_name=_('Circuits')
)
class Meta(NetBoxTable.Meta):
model = VirtualCircuitType
fields = (
'pk', 'id', 'name', 'virtual_circuit_count', 'color', 'description', 'slug', 'tags', 'created',
'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'virtual_circuit_count', 'color', 'description')
class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
@@ -29,6 +54,10 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_count = columns.LinkedCountColumn(
viewname='circuits:virtualcircuittermination_list',
@@ -45,12 +74,12 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
class Meta(NetBoxTable.Meta):
model = VirtualCircuit
fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'tenant_group',
'description', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'termination_count',
'description',
'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
'termination_count', 'description',
)

View File

@@ -295,7 +295,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroupAssignment
brief_fields = ['circuit', 'display', 'group', 'id', 'priority', 'url']
brief_fields = ['display', 'group', 'id', 'member', 'member_id', 'member_type', 'priority', 'url']
bulk_update_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}
@@ -330,17 +330,17 @@ class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
@@ -349,17 +349,20 @@ class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
cls.create_data = [
{
'group': circuit_groups[3].pk,
'circuit': circuits[3].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_PRIMARY,
},
{
'group': circuit_groups[4].pk,
'circuit': circuits[4].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[4].pk,
'priority': CircuitPriorityChoices.PRIORITY_SECONDARY,
},
{
'group': circuit_groups[5].pk,
'circuit': circuits[5].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[5].pk,
'priority': CircuitPriorityChoices.PRIORITY_TERTIARY,
},
]
@@ -406,6 +409,38 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
}
class VirtualCircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = VirtualCircuitType
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'virtual_circuit_count']
create_data = (
{
'name': 'Virtual Circuit Type 4',
'slug': 'virtual-circuit-type-4',
},
{
'name': 'Virtual Circuit Type 5',
'slug': 'virtual-circuit-type-5',
},
{
'name': 'Virtual Circuit Type 6',
'slug': 'virtual-circuit-type-6',
},
)
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
model = VirtualCircuit
brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
@@ -418,21 +453,28 @@ class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 1'
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 2'
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 3'
),
)
@@ -443,18 +485,21 @@ class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
'cid': 'Virtual Circuit 4',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
{
'cid': 'Virtual Circuit 5',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
{
'cid': 'Virtual Circuit 6',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
]
@@ -560,27 +605,35 @@ class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 1'
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 2'
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 3'
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 4'
cid='Virtual Circuit 4',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)

View File

@@ -648,7 +648,6 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
@@ -656,43 +655,86 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
Provider(name='Provider 4', slug='provider-4'),
))
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuittype),
Circuit(cid='Circuit 2', provider=providers[1], type=circuittype),
Circuit(cid='Circuit 3', provider=providers[2], type=circuittype),
Circuit(cid='Circuit 4', provider=providers[3], type=circuittype),
Circuit(cid='Circuit 1', provider=providers[0], type=circuit_type),
Circuit(cid='Circuit 2', provider=providers[1], type=circuit_type),
Circuit(cid='Circuit 3', provider=providers[2], type=circuit_type),
)
Circuit.objects.bulk_create(circuits)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[1],
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[2],
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
CircuitGroupAssignment(
group=circuit_groups[0],
member=virtual_circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=virtual_circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=virtual_circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
def test_group_id(self):
groups = CircuitGroup.objects.filter(name__in=['Circuit Group 1', 'Circuit Group 2'])
def test_group(self):
groups = CircuitGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_circuit(self):
circuits = Circuit.objects.all()[:2]
@@ -701,12 +743,19 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'circuit': [circuits[0].cid, circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_circuit(self):
virtual_circuits = VirtualCircuit.objects.all()[:2]
params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_circuit': [virtual_circuits[0].cid, virtual_circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -795,6 +844,36 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualCircuitType.objects.all()
filterset = VirtualCircuitTypeFilterSet
@classmethod
def setUpTestData(cls):
VirtualCircuitType.objects.bulk_create((
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1', description='foobar1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2', description='foobar2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Virtual Circuit Type 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_slug(self):
params = {'slug': ['virtual-circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualCircuit.objects.all()
filterset = VirtualCircuitFilterSet
@@ -838,12 +917,20 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderNetwork.objects.bulk_create(provider_networks)
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
virutal_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
tenant=tenants[0],
cid='Virtual Circuit 1',
type=virtual_circuit_types[0],
status=CircuitStatusChoices.STATUS_PLANNED,
description='virtualcircuit1',
),
@@ -852,6 +939,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
provider_account=provider_accounts[1],
tenant=tenants[1],
cid='Virtual Circuit 2',
type=virtual_circuit_types[1],
status=CircuitStatusChoices.STATUS_ACTIVE,
description='virtualcircuit2',
),
@@ -860,6 +948,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
provider_account=provider_accounts[2],
tenant=tenants[2],
cid='Virtual Circuit 3',
type=virtual_circuit_types[2],
status=CircuitStatusChoices.STATUS_DEPROVISIONING,
description='virtualcircuit3',
),
@@ -891,6 +980,13 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
virtual_circuit_types = VirtualCircuitType.objects.all()[:2]
params = {'type_id': [virtual_circuit_types[0].pk, virtual_circuit_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'type': [virtual_circuit_types[0].slug, virtual_circuit_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -987,22 +1083,29 @@ class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderAccount(provider=providers[2], account='Provider Account 3'),
)
ProviderAccount.objects.bulk_create(provider_accounts)
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 1'
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[1],
provider_account=provider_accounts[1],
cid='Virtual Circuit 2'
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[2],
provider_account=provider_accounts[2],
cid='Virtual Circuit 3'
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)

View File

@@ -468,6 +468,7 @@ class CircuitGroupAssignmentTestCase(
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = CircuitGroupAssignment
@@ -497,17 +498,17 @@ class CircuitGroupAssignmentTestCase(
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
@@ -517,16 +518,72 @@ class CircuitGroupAssignmentTestCase(
cls.form_data = {
'group': circuit_groups[3].pk,
'circuit': circuits[3].pk,
'member_type': ContentType.objects.get_for_model(Circuit).pk,
'member': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"member_type,member_id,group,priority",
f"circuits.circuit,{circuits[0].pk},{circuit_groups[3].pk},primary",
f"circuits.circuit,{circuits[1].pk},{circuit_groups[3].pk},secondary",
f"circuits.circuit,{circuits[2].pk},{circuit_groups[3].pk},tertiary",
)
cls.csv_update_data = (
"id,priority",
f"{assignments[0].pk},inactive",
f"{assignments[1].pk},inactive",
f"{assignments[2].pk},inactive",
)
cls.bulk_edit_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}
class VirtualCircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = VirtualCircuitType
@classmethod
def setUpTestData(cls):
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Virtual Circuit Type X',
'slug': 'virtual-circuit-type-x',
'description': 'A new virtual circuit type',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug",
"Virtual Circuit Type 4,circuit-type-4",
"Virtual Circuit Type 5,circuit-type-5",
"Virtual Circuit Type 6,circuit-type-6",
)
cls.csv_update_data = (
"id,name,description",
f"{virtual_circuit_types[0].pk},Virtual Circuit Type 7,New description7",
f"{virtual_circuit_types[1].pk},Virtual Circuit Type 8,New description8",
f"{virtual_circuit_types[2].pk},Virtual Circuit Type 9,New description9",
)
cls.bulk_edit_data = {
'description': 'Foo',
}
class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualCircuit
@@ -550,22 +607,30 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ProviderAccount(provider=provider, account='Provider Account 2'),
)
ProviderAccount.objects.bulk_create(provider_accounts)
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 1'
cid='Virtual Circuit 1',
type=virtual_circuit_types[0]
),
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 2'
cid='Virtual Circuit 2',
type=virtual_circuit_types[0]
),
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 3'
cid='Virtual Circuit 3',
type=virtual_circuit_types[0]
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
@@ -584,6 +649,7 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'cid': 'Virtual Circuit X',
'provider_network': provider_networks[1].pk,
'provider_account': provider_accounts[1].pk,
'type': virtual_circuit_types[1].pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
'description': 'A new virtual circuit',
'comments': 'Some comments',
@@ -591,22 +657,41 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"cid,provider_network,provider_account,status",
f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
"cid,provider_network,provider_account,type,status",
(
f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
(
f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
(
f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
)
cls.csv_update_data = (
"id,cid,description,status",
f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
"id,cid,description,type,status",
(
f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
(
f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
(
f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
)
cls.bulk_edit_data = {
'provider_network': provider_networks[1].pk,
'provider_account': provider_accounts[1].pk,
'type': virtual_circuit_types[1].pk,
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'description': 'New description',
'comments': 'New comments',
@@ -620,6 +705,7 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
{{
"cid": "Virtual Circuit 7",
"provider_network": "Provider Network 1",
"type": "Virtual Circuit Type 1",
"status": "active",
"terminations": [
{{
@@ -758,27 +844,35 @@ class VirtualCircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 1'
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 2'
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 3'
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 4'
cid='Virtual Circuit 4',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)

View File

@@ -42,6 +42,9 @@ urlpatterns = [
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
path('virtual-circuit-types/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittype'))),
# Virtual circuit terminations
path(
'virtual-circuit-terminations/',

View File

@@ -579,6 +579,67 @@ class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
table = tables.CircuitGroupAssignmentTable
#
# Virtual circuit Types
#
@register_model_view(VirtualCircuitType, 'list', path='', detail=False)
class VirtualCircuitTypeListView(generic.ObjectListView):
queryset = VirtualCircuitType.objects.annotate(
virtual_circuit_count=count_related(VirtualCircuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
filterset_form = forms.VirtualCircuitTypeFilterForm
table = tables.VirtualCircuitTypeTable
@register_model_view(VirtualCircuitType)
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualCircuitType.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(VirtualCircuitType, 'add', detail=False)
@register_model_view(VirtualCircuitType, 'edit')
class VirtualCircuitTypeEditView(generic.ObjectEditView):
queryset = VirtualCircuitType.objects.all()
form = forms.VirtualCircuitTypeForm
@register_model_view(VirtualCircuitType, 'delete')
class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm
@register_model_view(VirtualCircuitType, 'bulk_edit', path='edit', detail=False)
class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
queryset = VirtualCircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
table = tables.VirtualCircuitTypeTable
form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
table = tables.VirtualCircuitTypeTable
#
# Virtual circuits
#

View File

@@ -1,4 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.migrations.operations import AlterModelOptions
@@ -19,6 +21,11 @@ class CoreConfig(AppConfig):
from core.api import schema # noqa: F401
from netbox.models.features import register_models
from . import data_backends, events, search # noqa: F401
from netbox import context_managers # noqa: F401
# Register models
register_models(*self.get_models())
# Clear Redis cache on startup in development mode
if settings.DEBUG:
cache.clear()

View File

@@ -1,8 +1,11 @@
import logging
import requests
import sys
from netbox.jobs import JobRunner
from django.conf import settings
from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend
from .choices import DataSourceStatusChoices
from .choices import DataSourceStatusChoices, JobIntervalChoices
from .exceptions import SyncError
from .models import DataSource
@@ -31,3 +34,44 @@ class SyncDataSourceJob(JobRunner):
if type(e) is SyncError:
logging.error(e)
raise e
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class SystemHousekeepingJob(JobRunner):
"""
Perform daily system housekeeping functions.
"""
class Meta:
name = "System Housekeeping"
def run(self, *args, **kwargs):
# Skip if running in development or test mode
if settings.DEBUG or 'test' in sys.argv:
return
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
self.send_census_report()
@staticmethod
def send_census_report():
"""
Send a census report (if enabled).
"""
# Skip if census reporting is disabled
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED:
return
census_data = {
'version': settings.RELEASE.full_version,
'python_version': sys.version.split()[0],
'deployment_id': settings.DEPLOYMENT_ID,
}
try:
requests.get(
url=settings.CENSUS_URL,
params=census_data,
timeout=3,
proxies=settings.HTTP_PROXIES
)
except requests.exceptions.RequestException:
pass

View File

@@ -570,8 +570,9 @@ class SystemView(UserPassesTestMixin, View):
return response
# Serialize any CustomValidator classes
if hasattr(config, 'CUSTOM_VALIDATORS') and config.CUSTOM_VALIDATORS:
config.CUSTOM_VALIDATORS = json.dumps(config.CUSTOM_VALIDATORS, cls=ConfigJSONEncoder, indent=4)
for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
if hasattr(config, attr) and getattr(config, attr, None):
setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
return render(request, 'core/system.html', {
'stats': stats,
@@ -594,7 +595,7 @@ class BasePluginView(UserPassesTestMixin, View):
catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
if not catalog_plugins_error:
catalog_plugins = get_catalog_plugins()
if not catalog_plugins:
if not catalog_plugins and not settings.ISOLATED_DEPLOYMENT:
# Cache for 5 minutes to avoid spamming connection
cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
messages.warning(request, _("Plugins catalog could not be loaded"))

View File

@@ -170,8 +170,8 @@ class MACAddressSerializer(NetBoxModelSerializer):
class Meta:
model = MACAddress
fields = [
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object',
'description', 'comments',
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
'assigned_object', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'mac_address', 'description')

View File

@@ -362,6 +362,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
queryset=RackRole.objects.all(),
required=False
)
rack_type = DynamicModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
required=False,
)
serial = forms.CharField(
max_length=50,
required=False,
@@ -441,7 +446,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
model = Rack
fieldsets = (
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet(
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',

View File

@@ -258,6 +258,13 @@ class RackImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Name of assigned role')
)
rack_type = CSVModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
to_field_name='model',
required=False,
help_text=_('Rack type model')
)
form_factor = CSVChoiceField(
label=_('Type'),
choices=RackFormFactorChoices,
@@ -267,8 +274,13 @@ class RackImportForm(NetBoxModelImportForm):
width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False,
help_text=_('Rail-to-rail width (in inches)')
)
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices,
@@ -291,9 +303,9 @@ class RackImportForm(NetBoxModelImportForm):
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -305,6 +317,16 @@ class RackImportForm(NetBoxModelImportForm):
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
def clean(self):
super().clean()
# width & u_height must be set if not specifying a rack type on import
if not self.instance.pk:
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'):
raise forms.ValidationError(_("Width must be set if not specifying a rack type."))
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'):
raise forms.ValidationError(_("U height must be set if not specifying a rack type."))
class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(

View File

@@ -3,9 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import MACAddress
from utilities.forms import get_field_value
from utilities.forms.fields import DynamicModelChoiceField
__all__ = (
'InterfaceCommonForm',
@@ -20,12 +18,6 @@ class InterfaceCommonForm(forms.Form):
max_value=INTERFACE_MTU_MAX,
label=_('MTU')
)
primary_mac_address = DynamicModelChoiceField(
queryset=MACAddress.objects.all(),
label=_('Primary MAC address'),
required=False,
quick_add=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -1410,6 +1410,13 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('VRF')
)
primary_mac_address = DynamicModelChoiceField(
queryset=MACAddress.objects.all(),
label=_('Primary MAC address'),
required=False,
quick_add=True,
quick_add_params={'interface': '$pk'}
)
wwn = forms.CharField(
empty_value=None,
required=False,

View File

@@ -115,7 +115,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
filters=CableTerminationFilter
)
class CableTerminationType(NetBoxObjectType):
cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None
termination: Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')],
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')],

View File

@@ -605,6 +605,10 @@ class CablePath(models.Model):
cable_end = 'A' if lct.cable_end == 'B' else 'B'
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
# Make sure this filter has been populated; if not, we have probably been given invalid data
if not q_filter:
break
remote_cable_terminations = CableTermination.objects.filter(q_filter)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:

View File

@@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication:
create_instances.append(template_instance)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
for component in create_instances:
component.custom_field_data = cf_defaults
if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object

View File

@@ -1,6 +1,8 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.constants import LOCATION_SCOPE_TYPES
__all__ = (
@@ -84,6 +86,16 @@ class CachedScopeMixin(models.Model):
class Meta:
abstract = True
def clean(self):
if self.scope_type and not self.scope:
scope_type = self.scope_type.model_class()
raise ValidationError({
'scope': _(
"Please select a {scope_type}."
).format(scope_type=scope_type._meta.model_name)
})
super().clean()
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()

View File

@@ -374,22 +374,27 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
if not self._state.adding:
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
effective_u_height = self.rack_type.u_height if self.rack_type else self.u_height
effective_starting_unit = self.rack_type.starting_unit if self.rack_type else self.starting_unit
# Validate that Rack is tall enough to house the highest mounted Device
if top_device := mounted_devices.last():
min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
if self.u_height < min_height:
min_height = top_device.position + top_device.device_type.u_height - effective_starting_unit
if effective_u_height < min_height:
field = 'rack_type' if self.rack_type else 'u_height'
raise ValidationError({
'u_height': _(
field: _(
"Rack must be at least {min_height}U tall to house currently installed devices."
).format(min_height=min_height)
})
# Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
if last_device := mounted_devices.first():
if self.starting_unit > last_device.position:
if effective_starting_unit > last_device.position:
field = 'rack_type' if self.rack_type else 'starting_unit'
raise ValidationError({
'starting_unit': _("Rack unit numbering must begin at {position} or less to house "
"currently installed devices.").format(position=last_device.position)
field: _("Rack unit numbering must begin at {position} or less to house "
"currently installed devices.").format(position=last_device.position)
})
# Validate that Rack was assigned a Location of its same site, if applicable

View File

@@ -105,7 +105,7 @@ class MACAddressIndex(SearchIndex):
('mac_address', 100),
('description', 500),
)
display_attrs = ('mac_address', 'interface')
display_attrs = ('assigned_object', 'description')
@register_search

View File

@@ -85,7 +85,8 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if instance._terminations_modified:
a_terminations = []
b_terminations = []
for t in instance.terminations.all():
# Note: instance.terminations.all() is not safe to use here as it might be stale
for t in CableTermination.objects.filter(cable=instance):
if t.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(t.termination)
else:

View File

@@ -362,7 +362,7 @@ class CableTraceSVG:
self.cursor += CABLE_HEIGHT
# Connector (a Cable or WirelessLink)
if links:
if links and far_ends:
obj_list = {end.parent_object for end in far_ends}
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)

View File

@@ -48,6 +48,7 @@ def get_device_description(device):
Name: <name>
Role: <role>
Status: <status>
Device Type: <manufacturer> <model> (<u_height>)
Asset tag: <asset_tag> (if defined)
Serial: <serial> (if defined)
@@ -55,6 +56,7 @@ def get_device_description(device):
"""
description = f'Name: {device.name}'
description += f'\nRole: {device.role}'
description += f'\nStatus: {device.get_status_display()}'
u_height = f'{floatformat(device.device_type.u_height)}U'
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
if device.asset_tag:

View File

@@ -1155,6 +1155,7 @@ class MACAddressTable(NetBoxTable):
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'created', 'last_updated',
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object')
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')

View File

@@ -41,6 +41,7 @@ class ModuleTypeTable(NetBoxTable):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',
@@ -79,7 +80,7 @@ class ModuleTable(NetBoxTable):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags',
'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',

View File

@@ -69,16 +69,18 @@ INTERFACE_FHRPGROUPS = """
"""
INTERFACE_TAGGED_VLANS = """
{% if record.mode == 'tagged' %}
{% load i18n %}
{% if record.mode == 'access' %}
{% elif record.mode == 'tagged-all' %}
{% trans "All" %}
{% else %}
{% if value.count > 3 %}
<a href="{% url 'ipam:vlan_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }} VLANs</a>
{% else %}
{% for vlan in value.all %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %}
{% endif %}
{% elif record.mode == 'tagged-all' %}
All
{% endif %}
"""

View File

@@ -2447,3 +2447,46 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
# Omit identifier to test uniqueness constraint
},
]
class MACAddressTest(APIViewTestCases.APIViewTestCase):
model = MACAddress
brief_fields = ['description', 'display', 'id', 'mac_address', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
device = create_test_device(name='Device 1')
interfaces = (
Interface(device=device, name='Interface 1', type='1000base-t'),
Interface(device=device, name='Interface 2', type='1000base-t'),
Interface(device=device, name='Interface 3', type='1000base-t'),
Interface(device=device, name='Interface 4', type='1000base-t'),
Interface(device=device, name='Interface 5', type='1000base-t'),
)
Interface.objects.bulk_create(interfaces)
mac_addresses = (
MACAddress(mac_address='00:00:00:00:00:01', assigned_object=interfaces[0]),
MACAddress(mac_address='00:00:00:00:00:02', assigned_object=interfaces[1]),
MACAddress(mac_address='00:00:00:00:00:03', assigned_object=interfaces[2]),
)
MACAddress.objects.bulk_create(mac_addresses)
cls.create_data = [
{
'mac_address': '00:00:00:00:00:04',
'assigned_object_type': 'dcim.interface',
'assigned_object_id': interfaces[3].pk,
},
{
'mac_address': '00:00:00:00:00:05',
'assigned_object_type': 'dcim.interface',
'assigned_object_id': interfaces[4].pk,
},
{
'mac_address': '00:00:00:00:00:06',
},
]

View File

@@ -3470,3 +3470,54 @@ class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
}
class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = MACAddress
@classmethod
def setUpTestData(cls):
device = create_test_device(name='Device 1')
interfaces = (
Interface(device=device, name='Interface 1', type='1000base-t'),
Interface(device=device, name='Interface 2', type='1000base-t'),
Interface(device=device, name='Interface 3', type='1000base-t'),
Interface(device=device, name='Interface 4', type='1000base-t'),
Interface(device=device, name='Interface 5', type='1000base-t'),
Interface(device=device, name='Interface 6', type='1000base-t'),
)
Interface.objects.bulk_create(interfaces)
mac_addresses = (
MACAddress(mac_address='00:00:00:00:00:01', assigned_object=interfaces[0]),
MACAddress(mac_address='00:00:00:00:00:02', assigned_object=interfaces[1]),
MACAddress(mac_address='00:00:00:00:00:03', assigned_object=interfaces[2]),
)
MACAddress.objects.bulk_create(mac_addresses)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'mac_address': EUI('00:00:00:00:00:04'),
'description': 'New MAC address',
'interface_id': interfaces[3].pk,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"mac_address,device,interface",
"00:00:00:00:00:04,Device 1,Interface 4",
"00:00:00:00:00:05,Device 1,Interface 5",
"00:00:00:00:00:06,Device 1,Interface 6",
)
cls.csv_update_data = (
"id,mac_address",
f"{mac_addresses[0].pk},00:00:00:00:00:0a",
f"{mac_addresses[1].pk},00:00:00:00:00:0b",
f"{mac_addresses[2].pk},00:00:00:00:00:0c",
)
cls.bulk_edit_data = {
'description': 'New description',
}

View File

@@ -1,5 +1,3 @@
import traceback
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -17,7 +15,7 @@ from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, VLANGroup
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
@@ -32,8 +30,9 @@ from utilities.views import (
)
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import VirtualMachineTable
from wireless.models import WirelessLAN
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
@@ -240,6 +239,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
regions,
omit=(Cluster, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
@@ -249,6 +249,11 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
).distinct(),
'region_id'
),
# Handle these relations manually to avoid erroneous filter name resolution
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
),
),
}
@@ -333,6 +338,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
groups,
omit=(Cluster, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
@@ -342,6 +348,20 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
).distinct(),
'site_group_id'
),
# Handle these relations manually to avoid erroneous filter name resolution
(
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
),
(
Prefix.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
),
(
WirelessLAN.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
),
),
),
}
@@ -420,8 +440,8 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
instance,
[CableTermination, CircuitTermination],
(
omit=(CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN),
extra=(
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
@@ -431,6 +451,11 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(),
'site_id'
),
# Handle these relations manually to avoid erroneous filter name resolution
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
),
),
}
@@ -508,14 +533,19 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
locations,
[CableTermination],
(
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(
terminations___location=instance
).distinct(),
'location_id'
),
# Handle these relations manually to avoid erroneous filter name resolution
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
),
),
}
@@ -2238,7 +2268,8 @@ class DeviceRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text')
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@@ -2256,17 +2287,18 @@ class DeviceRenderConfigView(generic.ObjectView):
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
rendered_config = traceback.format_exc()
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}

View File

@@ -79,6 +79,7 @@ DEFAULT_DASHBOARD = [
'feed_url': 'http://netbox.dev/rss/',
'max_entries': 10,
'cache_timeout': 14400,
'requires_internet': True,
}
},
{

View File

@@ -275,6 +275,7 @@ class RSSFeedWidget(DashboardWidget):
default_config = {
'max_entries': 10,
'cache_timeout': 3600, # seconds
'requires_internet': True,
}
description = _('Embed an RSS feed from an external website.')
template_name = 'extras/dashboard/widgets/rssfeed.html'
@@ -285,6 +286,10 @@ class RSSFeedWidget(DashboardWidget):
feed_url = forms.URLField(
label=_('Feed URL')
)
requires_internet = forms.BooleanField(
label=_('Requires external connection'),
required=False,
)
max_entries = forms.IntegerField(
min_value=1,
max_value=1000,
@@ -309,6 +314,11 @@ class RSSFeedWidget(DashboardWidget):
return f'dashboard_rss_{url_checksum}'
def get_feed(self):
if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
return {
'isolated_deployment': True,
}
# Fetch RSS content from cache if available
if feed_content := cache.get(self.cache_key):
return {

View File

@@ -90,6 +90,10 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
if not event_rule.eval_conditions(data):
continue
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(data)
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -102,7 +106,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
"event_rule": event_rule,
"model_name": object_type.model,
"event_type": event_type,
"data": data,
"data": event_data,
"snapshots": snapshots,
"timestamp": timezone.now().isoformat(),
"username": username,
@@ -130,7 +134,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
instance=event_rule.action_object,
name=script.name,
user=user,
data=data
data=event_data
)
# Notification groups
@@ -138,8 +142,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
# Bulk-create notifications for all members of the notification group
event_rule.action_object.notify(
object_type=object_type,
object_id=data['id'],
object_repr=data.get('display'),
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event_type
)

View File

@@ -1,14 +1,14 @@
import logging
import traceback
from contextlib import nullcontext
from contextlib import ExitStack
from django.db import transaction
from django.utils.translation import gettext as _
from core.signals import clear_events
from extras.models import Script as ScriptModel
from netbox.context_managers import event_tracking
from netbox.jobs import JobRunner
from netbox.registry import registry
from utilities.exceptions import AbortScript, AbortTransaction
from .utils import is_report
@@ -100,5 +100,7 @@ class ScriptJob(JobRunner):
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc.
with event_tracking(request) if commit else nullcontext():
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit)

View File

@@ -120,11 +120,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
is_active=True,
)
# Apply Location & DeviceType filters only for VirtualMachines
if self.model._meta.model_name == 'device':
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
elif self.model._meta.model_name == 'virtualmachine':
base_query.add(Q(locations=None), Q.AND)
base_query.add(Q(device_types=None), Q.AND)
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)

View File

@@ -50,21 +50,24 @@ class EventRuleTest(APITestCase):
event_types=[OBJECT_CREATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
action_object_id=webhooks[0].id,
action_data={"foo": 1},
),
EventRule(
name='Event Rule 2',
event_types=[OBJECT_UPDATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
action_object_id=webhooks[0].id,
action_data={"foo": 2},
),
EventRule(
name='Event Rule 3',
event_types=[OBJECT_DELETED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
action_object_id=webhooks[0].id,
action_data={"foo": 3},
),
))
for event_rule in event_rules:
@@ -134,6 +137,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
self.assertEqual(job.kwargs['data']['foo'], 1)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
@@ -184,6 +188,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
self.assertEqual(job.kwargs['data']['foo'], 1)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
@@ -215,6 +220,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['data']['foo'], 2)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
@@ -271,6 +277,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
self.assertEqual(job.kwargs['data']['foo'], 2)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
@@ -297,6 +304,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['data']['foo'], 3)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
@@ -330,6 +338,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
self.assertEqual(job.kwargs['data']['foo'], 3)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
@@ -358,6 +367,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(body['username'], 'testuser')
self.assertEqual(body['request_id'], str(request_id))
self.assertEqual(body['data']['name'], 'Site 1')
self.assertEqual(body['data']['foo'], 1)
return HttpResponse()

View File

@@ -1170,6 +1170,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'virtualchassis',
'virtualcircuit',
'virtualcircuittermination',
'virtualcircuittype',
'virtualdevicecontext',
'virtualdisk',
'virtualmachine',

View File

@@ -1210,12 +1210,14 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'object': script,
'script': script,
})
form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', {
'object': script,
'script': script,
'script_class': script_class,
'form': form,
@@ -1231,6 +1233,7 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'object': script,
'script': script,
})
@@ -1255,6 +1258,7 @@ class ScriptView(BaseScriptView):
return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', {
'object': script,
'script': script,
'script_class': script.python_class(),
'form': form,

View File

@@ -214,8 +214,10 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
return queryset.filter(qs_filter)
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
@@ -1056,7 +1058,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.filter(
Q(interfaces_as_tagged=value) |
Q(interfaces_as_untagged=value)
)
).distinct()
def filter_vminterface_id(self, queryset, name, value):
if value is None:
@@ -1064,7 +1066,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.filter(
Q(vminterfaces_as_tagged=value) |
Q(vminterfaces_as_untagged=value)
)
).distinct()
class VLANTranslationPolicyFilterSet(NetBoxModelFilterSet):

View File

@@ -325,12 +325,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
help_text=_('Make this the primary IP for the assigned device'),
required=False
)
is_oob = forms.BooleanField(
label=_('Is out-of-band'),
help_text=_('Designate this as the out-of-band IP address for the assigned device'),
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'dns_name', 'description', 'comments', 'tags',
'is_oob', 'dns_name', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -344,7 +349,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
# Limit interface queryset by assigned VM
elif data.get('virtual_machine'):
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
@@ -357,16 +362,29 @@ class IPAddressImportForm(NetBoxModelImportForm):
virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
is_primary = self.cleaned_data.get('is_primary')
is_oob = self.cleaned_data.get('is_oob')
# Validate is_primary
# Validate is_primary and is_oob
if is_primary and not device and not virtual_machine:
raise forms.ValidationError({
"is_primary": _("No device or virtual machine specified; cannot set as primary IP")
})
if is_oob and not device:
raise forms.ValidationError({
"is_oob": _("No device specified; cannot set as out-of-band IP")
})
if is_oob and virtual_machine:
raise forms.ValidationError({
"is_oob": _("Cannot set out-of-band IP for virtual machines")
})
if is_primary and not interface:
raise forms.ValidationError({
"is_primary": _("No interface specified; cannot set as primary IP")
})
if is_oob and not interface:
raise forms.ValidationError({
"is_oob": _("No interface specified; cannot set as out-of-band IP")
})
def save(self, *args, **kwargs):
@@ -385,6 +403,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
parent.primary_ip6 = ipaddress
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob'):
parent = self.cleaned_data.get('device')
parent.oob_ip = ipaddress
parent.save()
return ipaddress

View File

@@ -311,6 +311,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False,
label=_('Make this the primary IP for the device/VM')
)
oob_for_parent = forms.BooleanField(
required=False,
label=_('Make this the out-of-band IP for the device')
)
comments = CommentField()
fieldsets = (
@@ -322,7 +326,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
FieldSet('vminterface', name=_('Virtual Machine')),
FieldSet('fhrpgroup', name=_('FHRP Group')),
),
'primary_for_parent', name=_('Assignment')
'primary_for_parent', 'oob_for_parent', name=_('Assignment')
),
FieldSet('nat_inside', name=_('NAT IP (Inside)')),
)
@@ -330,8 +334,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
'tenant', 'description', 'comments', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
'tenant_group', 'tenant', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -350,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
super().__init__(*args, **kwargs)
# Initialize primary_for_parent if IP address is already assigned
# Initialize parent object & fields if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
parent = getattr(self.instance.assigned_object, 'parent_object', None)
if parent and (
@@ -359,6 +363,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
if parent and getattr(parent, 'oob_ip_id', None) == self.instance.pk:
self.initial['oob_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
@@ -387,15 +394,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if (
self.instance.pk and
self.instance.assigned_object and
self.cleaned_data['primary_for_parent'] and
assigned_object != self.instance.assigned_object
):
raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)
if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object:
if self.cleaned_data['primary_for_parent']:
raise ValidationError(
_("Cannot reassign primary IP address for the parent device/VM")
)
if self.cleaned_data['oob_for_parent']:
raise ValidationError(
_("Cannot reassign out-of-Band IP address for the parent device")
)
self.instance.assigned_object = assigned_object
else:
self.instance.assigned_object = None
@@ -407,6 +414,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
)
# OOB IP assignment is only available if device interface has been assigned.
interface = self.cleaned_data.get('interface')
if self.cleaned_data.get('oob_for_parent') and not interface:
self.add_error(
'oob_for_parent', _(
"Only IP addresses assigned to a device interface can be designated as the out-of-band IP for a "
"device."
)
)
def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs)
@@ -428,6 +445,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
parent.primary_ip6 = None
parent.save()
# Assign/clear this IPAddress as the OOB for the associated Device
if type(interface) is Interface:
parent = interface.parent_object
parent.snapshot()
if self.cleaned_data['oob_for_parent']:
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
parent.oob_ip = None
parent.save()
return ipaddress

View File

@@ -79,7 +79,7 @@ class PrefixIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
display_attrs = ('scope', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
@register_search

View File

@@ -192,7 +192,8 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
)
scope = tables.Column(
verbose_name=_('Scope'),
linkify=True
linkify=True,
orderable=False
)
vlan_group = tables.Column(
accessor='vlan__group',
@@ -200,6 +201,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('VLAN Group')
)
vlan = tables.Column(
order_by=('vlan__vid', 'vlan__pk'),
linkify=True,
verbose_name=_('VLAN')
)

View File

@@ -713,7 +713,7 @@ class IPRangeView(generic.ObjectView):
Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
vrf=instance.vrf
).prefetch_related(
'site', 'role', 'tenant', 'vlan', 'role'
'scope', 'role', 'tenant', 'vlan', 'role'
)
parent_prefixes_table = tables.PrefixTable(
list(parent_prefixes),
@@ -805,7 +805,7 @@ class IPAddressView(generic.ObjectView):
vrf=instance.vrf,
prefix__net_contains_or_equals=str(instance.address.ip)
).prefetch_related(
'site', 'role'
'scope', 'role'
)
parent_prefixes_table = tables.PrefixTable(
list(parent_prefixes),
@@ -1288,7 +1288,7 @@ class VLANView(generic.ObjectView):
def get_extra_context(self, request, instance):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'site', 'role', 'tenant'
'vrf', 'scope', 'role', 'tenant'
)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)

View File

@@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def get_limit(self, request):
if self.limit_query_param:
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
return limit

View File

@@ -76,6 +76,12 @@ class ValidatedModelSerializer(BaseModelSerializer):
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
# Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
# on our own custom model validation (below).
def get_unique_together_constraints(self, model):
return []
def validate(self, data):
# Skip validation if we're being used to represent a nested object

View File

@@ -1,9 +1,11 @@
from contextlib import contextmanager
from netbox.context import current_request, events_queue
from netbox.utils import register_request_processor
from extras.events import flush_events
@register_request_processor
@contextmanager
def event_tracking(request):
"""

View File

@@ -1,3 +1,5 @@
from contextlib import ExitStack
import logging
import uuid
@@ -10,7 +12,7 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect
from netbox.config import clear_config, get_config
from netbox.context_managers import event_tracking
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
@@ -32,8 +34,10 @@ class CoreMiddleware:
# Assign a random unique ID to the request. This will be used for change logging.
request.id = uuid.uuid4()
# Enable the event_tracking context manager and process the request.
with event_tracking(request):
# Apply all registered request processors
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
response = self.get_response(request)
# Check if language cookie should be renewed

View File

@@ -279,8 +279,6 @@ CIRCUITS_MENU = Menu(
items=(
get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuitgroup', _('Circuit Groups')),
get_model_item('circuits', 'circuitgroupassignment', _('Group Assignments')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
),
),
@@ -288,9 +286,17 @@ CIRCUITS_MENU = Menu(
label=_('Virtual Circuits'),
items=(
get_model_item('circuits', 'virtualcircuit', _('Virtual Circuits')),
get_model_item('circuits', 'virtualcircuittype', _('Virtual Circuit Types')),
get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')),
),
),
MenuGroup(
label=_('Groups'),
items=(
get_model_item('circuits', 'circuitgroup', _('Circuit Groups')),
get_model_item('circuits', 'circuitgroupassignment', _('Group Assignments')),
),
),
MenuGroup(
label=_('Providers'),
items=(

View File

@@ -29,6 +29,7 @@ registry = Registry({
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),
'request_processors': list(),
'search': dict(),
'system_jobs': dict(),
'tables': collections.defaultdict(dict),

View File

@@ -5,9 +5,7 @@ import os
import platform
import sys
import warnings
from urllib.parse import urlencode
import requests
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
@@ -224,8 +222,18 @@ DATABASES = {
# Storage backend
#
# Default STORAGES for Django
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
if STORAGE_BACKEND is not None:
DEFAULT_FILE_STORAGE = STORAGE_BACKEND
STORAGES['default']['BACKEND'] = STORAGE_BACKEND
# django-storages
if STORAGE_BACKEND.startswith('storages.'):
@@ -583,17 +591,6 @@ if SENTRY_ENABLED:
# Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
CENSUS_PARAMS = {
'version': RELEASE.full_version,
'python_version': sys.version.split()[0],
'deployment_id': DEPLOYMENT_ID,
}
if CENSUS_REPORTING_ENABLED and not ISOLATED_DEPLOYMENT and not DEBUG and 'test' not in sys.argv:
try:
# Report anonymous census data
requests.get(f'{CENSUS_URL}?{urlencode(CENSUS_PARAMS)}', timeout=3, proxies=HTTP_PROXIES)
except requests.exceptions.RequestException:
pass
#

View File

@@ -3,6 +3,7 @@ from netbox.registry import registry
__all__ = (
'get_data_backend_choices',
'register_data_backend',
'register_request_processor',
)
@@ -24,3 +25,12 @@ def register_data_backend():
return cls
return _wrapper
def register_request_processor(func):
"""
Decorator for registering a request processor.
"""
registry['request_processors'].append(func)
return func

View File

@@ -661,15 +661,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
elif 'virtual_machine' in request.GET:
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
if '_apply' in request.POST:
form = self.form(request.POST, initial=initial_data)
restrict_form_fields(form, request.user)
form = self.form(request.POST, initial=initial_data)
restrict_form_fields(form, request.user)
if '_apply' in request.POST:
if form.is_valid():
logger.debug("Form validation was successful")
try:
with transaction.atomic():
updated_objects = self._update_objects(form, request)
@@ -697,10 +695,6 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
else:
logger.debug("Form validation failed")
else:
form = self.form(initial=initial_data)
restrict_form_fields(form, request.user)
# Retrieve objects being edited
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
if not table.rows:
@@ -744,7 +738,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = []
for obj in selected_objects:
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
@@ -758,7 +751,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
except re.error:
obj.new_name = obj.name
else:
obj.new_name = obj.name.replace(find, replace)
obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks
@@ -793,6 +786,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
)
return redirect(self.get_return_url(request))
except IntegrityError as e:
messages.error(self.request, ", ".join(e.args))
clear_events.send(sender=self)
except (AbortRequest, PermissionsViolation) as e:
logger.debug(e.message)
form.add_error(None, e.message)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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