Compare commits

..

139 Commits

Author SHA1 Message Date
Jeremy Stretch
04137e887e Merge pull request #11376 from netbox-community/develop
Release v3.4.2
2023-01-03 16:28:05 -05:00
jeremystretch
e940f00c01 Release v3.4.2 2023-01-03 16:13:11 -05:00
jeremystretch
1c72a80d9a Changelog for #11156, #11259, #11342, #11345 2023-01-03 10:21:19 -05:00
kkthxbye
b9f8370097 Fixes #11156 - Allow InventoryItem component reassignment (#11256)
* Allow re-assigning InventoryItem components

* Refactor logic for finding initial component assignment on InventoryItems

* PEP8 fix

* Fix wrong HTML causing tab list to extend past the end of the parent row

* Tweak form field labels

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 10:13:34 -05:00
Christian Harendt
1c636ea127 add username for redis authentication 2023-01-03 09:42:18 -05:00
kkthxbye
e1169e7ea6 Fixes #11345 - Fix module validation (#11346)
* Make sure we bail out if field validation failed when importing modules

* Tweak form validation logic

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 09:14:25 -05:00
kkthxbye-code
5975dbcb07 Fix component traces all pointing to the interface trace URL 2023-01-03 08:25:28 -05:00
Arthur Hanson
08a419ec7a 11271 flag to disable localization (#11323)
* 11271 flag to disable localization

* 11271 change to remove middleware

* 11271 update docs for new var

* Update docs/configuration/system.md

Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>
2022-12-29 09:04:35 -05:00
jeremystretch
d417168805 Changelog for #11223, #11244, #11248 2022-12-28 16:58:04 -05:00
Mario
ccb2966c4c Fixes #11244: Elevations: Filter badge missing (#11321)
* Added filter badge in rack elevation

* Tweak template context

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-12-28 16:54:33 -05:00
Alef Burzmali
b7cdbd3d41 Fixes #11248 - Reindex only NetBox apps 2022-12-28 16:35:05 -05:00
Alef Burzmali
ae440c9edf Fixes #11223 - Accept app_label for reindex 2022-12-28 16:35:05 -05:00
jeremystretch
b6cd099117 Changelog for #11121, #11280 2022-12-27 16:07:02 -05:00
Arthur
98f57f2dba 11297 have custom field form display content-type instead of model 2022-12-27 15:50:08 -05:00
kkthxbye-code
735fa4aa31 Add summed resource card to cluster view 2022-12-27 10:24:46 -05:00
kkthxbye
c7108bb3f7 Fixes #11280 - Fix exporting interfaces and FHRP group rows with multiple IP's assigned (#11285)
undefined
2022-12-27 10:15:28 -05:00
jeremystretch
98b3fc03b8 Changelog for #9285, #10700, #11290 2022-12-22 10:14:38 -05:00
kkthxbye-code
92da2fe082 Add device name as part of module search for the q filter 2022-12-22 10:09:53 -05:00
kkthxbye-code
bfab3a26bc Add component import to InventoryItem bulk import 2022-12-22 09:59:50 -05:00
kkthxbye-code
b35b33e798 Use the start time to calculate duration of jobs instead of created time 2022-12-22 09:52:05 -05:00
jeremystretch
db5c2a379e Fixes #11232: Enable partial & regex matching for non-string types in global search 2022-12-22 09:14:57 -05:00
jeremystretch
3675ad2539 PRVB 2022-12-16 17:18:06 -05:00
Jeremy Stretch
27c71b8ec0 Merge pull request #11219 from netbox-community/develop
Release v3.4.1
2022-12-16 17:16:07 -05:00
jeremystretch
0058c7749c Release v3.4.1 2022-12-16 17:03:40 -05:00
jeremystretch
f882dcabf7 Fixes #11184: Correct visualization of cable path which splits across multiple circuit terminations 2022-12-16 16:45:51 -05:00
Arthur Hanson
c8f4a7c742 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS (#11207)
* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS

* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS
2022-12-16 08:59:24 -05:00
jeremystretch
ed366c5ab2 Closes #11214: Introduce the DEFAULT_LANGUAGE configuration parameter 2022-12-16 08:56:14 -05:00
jeremystretch
2738da2d39 Fixes #11189: Fix localization of dates & numbers 2022-12-16 08:43:05 -05:00
jeremystretch
9f15ca2d90 Closes #9971: Enable ordering of nested group models by name 2022-12-15 16:21:30 -05:00
jeremystretch
e4f5407c70 Changelog for #11175, #11178 2022-12-15 16:05:43 -05:00
jeremystretch
951f82b428 Fixes #11205: Correct cloning behavior for recursively-nested models 2022-12-15 16:04:29 -05:00
Arthur Hanson
f8685ad7aa 11175 fix cloning special chars in fields (#11181)
* 11175 fix cloning special chars in fields

* 11175 fix cloning special chars in fields
2022-12-15 13:07:55 -05:00
Arthur
c59d527664 11178 fix quick search press enter button 2022-12-15 13:01:21 -05:00
jeremystretch
77423e7bb1 Fixes #11185: Fix TemplateSyntaxError when viewing custom script results 2022-12-15 12:55:09 -05:00
jeremystretch
ba12675267 PRVB 2022-12-14 14:24:46 -05:00
Jeremy Stretch
def3ccfaee Merge pull request #11180 from netbox-community/develop
Release v3.4.0
2022-12-14 14:18:49 -05:00
jeremystretch
bbc68f9484 Release v3.4.0 2022-12-14 13:10:22 -05:00
Jeremy Stretch
93685d92a4 Merge pull request #11179 from netbox-community/feature
Prepare for v3.4.0 release
2022-12-14 11:56:29 -05:00
Arthur Hanson
b2bf613895 11171 fix graphql related models query (#11172)
* 11171 fix graphql related models query

* 11171 remove redundant code from code review

* 11171 remove redundant code from code review
2022-12-14 11:32:22 -05:00
jeremystretch
80ced6b782 Closes #11163: Auto-detect data format during bulk import 2022-12-14 10:09:59 -05:00
jeremystretch
47dfb89c52 Relocate ImportFormatChoices 2022-12-14 09:30:10 -05:00
jeremystretch
064e3ff605 Merge branch 'develop' into feature 2022-12-13 17:17:05 -05:00
Jeremy Stretch
fb27803ab0 Merge pull request #11174 from netbox-community/develop
Release v3.3.10
2022-12-13 15:44:42 -05:00
jeremystretch
5e32b39f25 Release v3.3.10 2022-12-13 15:29:07 -05:00
jeremystretch
b9888d6f86 Fixes #11109: Fix nullification of custom object & multi-object fields via REST API 2022-12-13 14:48:40 -05:00
jeremystretch
96a796ebde Fixes #11173: Enable missing tags columns for contact, L2VPN lists 2022-12-13 14:04:50 -05:00
jeremystretch
996e73d5d8 Fixes #10981: Fix release notes formatting 2022-12-13 13:26:41 -05:00
jeremystretch
5c969a8caf Changelog for #9361, #10447, #11077 2022-12-13 13:24:07 -05:00
jeremystretch
68faab8196 Fixes #11168: Honor RQ_DEFAULT_TIMEOUT config parameter when using Redis Sentinel 2022-12-13 13:22:28 -05:00
sleepinggenius2
b3693099dc Adds replication and adoption for module import (#9498)
* Adds replication and adoption for module import

* Moves common Module form clean logic to new class

* Adds tests for replication and adoption for module import

* Fix test

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 11:33:09 -05:00
Arthur Hanson
9bb9ac3dec 11077 use formatting for custom field date (#11143)
* 11077 use formatting for custom field date

* Apply configured date format to column render() method

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 09:22:57 -05:00
kkthxbye-code
a57378e780 Add missing newline and change wording of InventoryItem validation 2022-12-13 08:58:57 -05:00
kkthxbye-code
41f631b65b Allow re-assigning inventoryitems to other devices 2022-12-13 08:58:57 -05:00
jeremystretch
2db668f5cc Clean up v3.4 release notes 2022-12-12 16:49:00 -05:00
jeremystretch
aacf606999 #10739: Collapse BaseView class 2022-12-12 16:32:55 -05:00
jeremystretch
e338f7cfe3 #10371: Fix API serializer representation for module status 2022-12-12 16:14:18 -05:00
jeremystretch
758030733c #8366: Misc cleanup 2022-12-12 15:27:37 -05:00
jeremystretch
ad78f9e075 #7961: Fix updating via import when custom fields are absent 2022-12-12 14:19:27 -05:00
jeremystretch
3468e8c8ae #9623: Misc cleanup 2022-12-12 12:56:38 -05:00
jeremystretch
13d39a28ce #7854: Misc cleanup 2022-12-12 12:34:05 -05:00
jeremystretch
8809fc949b Fixes #11154: Index VM interface MAC address and MTU for global search 2022-12-12 10:43:48 -05:00
jeremystretch
860805ba82 Closes #10255: Introduce LOGOUT_REDIRECT_URL config parameter to control redirection of user after logout 2022-12-09 17:08:07 -05:00
jeremystretch
1e0b024609 Closes #10516: Add vertical frame & cabinet rack types 2022-12-09 16:35:37 -05:00
jeremystretch
8486d47d17 Fixes #11142: Correct available choices for status under IP range filter form 2022-12-09 16:04:46 -05:00
jeremystretch
407365888a Closes #11089: Permit whitespace in MAC addresses 2022-12-09 16:00:11 -05:00
jeremystretch
2ad1db0c64 Tweak migration name 2022-12-09 15:07:10 -05:00
jeremystretch
83a0576ca4 #9072: Add weight parameter to influence ViewTab ordering 2022-12-09 14:50:13 -05:00
jeremystretch
0b100b8fc8 Closes #10675: Add max_weight field to track maximum load capacity for racks 2022-12-09 12:45:02 -05:00
jeremystretch
2b12138c41 Fix configuration key for WirelessLANStatusChoices 2022-12-09 10:45:02 -05:00
jeremystretch
97aa40f7a8 Closes #10371: Add operational status field for modules 2022-12-09 10:43:29 -05:00
jeremystretch
b2f34cec19 Fixes #10950: Fix validation of VDC primary IPs 2022-12-09 09:49:01 -05:00
jeremystretch
3bc9586b0c Changelog for #10945 2022-12-08 18:18:51 -05:00
Jeremy Stretch
4297c65f87 Closes #10945: Enable recurring execution of scheduled reports & scripts (#11096)
* Add interval to JobResult

* Accept a recurrence interval when executing scripts & reports

* Cleaned up jobs list display

* Schedule next job only if a reference start time can be determined

* Improve validation for scheduled jobs
2022-12-08 18:17:13 -05:00
jeremystretch
62b0f034e7 Changelog for #11022 2022-12-08 10:00:21 -05:00
jeremystretch
6ffd8aa320 Introduce constants for RQ queue names 2022-12-08 09:58:52 -05:00
kkthxbye-code
d53ddd611b Add any queues defined in QUEUE_MAPPINGS to RQ_QUEUES 2022-12-08 09:45:21 -05:00
kkthxbye-code
080a001118 Allow redefining internally used queues 2022-12-08 09:45:21 -05:00
jeremystretch
5a77791f9d Merge branch 'develop' into feature 2022-12-08 09:31:22 -05:00
jeremystretch
ab9c253310 Fixes #11128: Disable ordering changelog table by object to avoid exception 2022-12-08 09:00:02 -05:00
jeremystretch
35596ddcbc Closes #10806: Add warning to run deactivate prior to upgrade script 2022-12-08 09:00:02 -05:00
kkthxbye-code
0cacac82ee Disable sorting by object_repr on ObjectChangeTable 2022-12-08 08:44:11 -05:00
Jeremy Stretch
780997a568 Closes #11119: Enable filtering L2VPNs by slug 2022-12-06 15:48:22 -05:00
Jeremy Stretch
d2d60c0607 Fixes #11087: Fix background color of bottom banner content 2022-12-06 15:40:59 -05:00
Renato Almeida de Oliveira
d4d8d00d01 add distinct method to circuit_count 2022-12-06 15:19:35 -05:00
jeremystretch
cb52d9c84e Changelog for #11000, #11046 2022-12-02 13:02:13 -05:00
jeremystretch
db61e57893 Closes #11090: Add regular expression support to global search engine 2022-12-02 12:54:35 -05:00
Jeremy Stretch
52cf9086a5 Fixes #11046: Restrict length of indexed search values (#11076)
* Fixes #11046: Restrict length of indexed search values

* Reference constant in index declaration

* Remove index from CachedValue.value
2022-12-02 10:07:53 -05:00
jeremystretch
db7590df1a Changelog for #10748, #11041 2022-12-02 09:30:44 -05:00
PieterL75
ee03f3d584 10748 Add 'Provider' to the circuit termination edit/view (#10939)
* Show the Provider of the NetworkProvider

* Clean up form fields

Co-authored-by: Pieter Lambrecht <pieter.lambrecht@sentia.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-02 09:27:47 -05:00
Arthur Hanson
2577f3a786 11000 improve yaml import (#11075)
* 11000 improve yaml import

* 11000 add commenting

* 11000 add commenting
2022-12-02 08:34:24 -05:00
Arthur
826a1714c3 11041 return power percentage with 1 decimal place 2022-12-01 15:41:15 -05:00
jeremystretch
d0e0c2ff8b Merge branch 'develop' into feature 2022-11-30 16:21:20 -05:00
jeremystretch
fb407e9076 PRVB 2022-11-30 16:18:03 -05:00
Jeremy Stretch
85c60670dc Merge pull request #11059 from netbox-community/develop
Release v3.3.9
2022-11-30 16:14:00 -05:00
jeremystretch
f2f36c67f6 Release v3.3.9 2022-11-30 15:51:37 -05:00
jeremystretch
281934cf34 Fixes #11047: Cloning a rack reservation should replicate rack & user 2022-11-30 15:37:50 -05:00
jeremystretch
00d72f18cf Annotate need for natural ordering 2022-11-30 15:33:01 -05:00
Arthur
b36afdc924 11014 code review changes 2022-11-30 15:33:01 -05:00
Arthur
4ed45e4031 11014 fix rack elevation name sorting 2022-11-30 15:33:01 -05:00
Arthur
cf0258204f 11048 fix connected_endpoint refs 2022-11-30 15:13:45 -05:00
Patrick Hurrelmann
3bd560add8 Fixes #11028 - Enable clearing of the color field for front and rear ports in bulk edit 2022-11-30 15:09:07 -05:00
Arthur
9e51a8d9d2 10999 fix power utilization on Device detail 2022-11-29 09:38:04 -05:00
Arthur
f59c6699f6 11025 fix error tag toast 2022-11-29 09:36:48 -05:00
jeremystretch
39732fa861 Merge branch 'develop' into feature 2022-11-29 09:19:11 -05:00
jeremystretch
80f5eeacdd Fix issues loading demo data 2022-11-29 09:18:03 -05:00
jeremystretch
f56e3eb784 Fixes #8058: Display server-side form errors inline with fields 2022-11-22 12:02:21 -05:00
jeremystretch
c3dcd8937f Merge branch 'develop' into feature 2022-11-22 10:08:23 -05:00
jeremystretch
b1da374df2 Fixes #10997: Fix exception when editing NAT IP for VM with no cluster 2022-11-22 08:52:21 -05:00
jeremystretch
dc1da0a738 Fixes #10996: Hide checkboxes on child object lists when no bulk operations are available 2022-11-22 08:52:04 -05:00
jeremystretch
1946e8f053 #10984: Update test 2022-11-21 16:05:51 -05:00
jeremystretch
4623858849 Fixes #10936: Permit demotion of device/VM primary IP via IP address edit form 2022-11-21 15:36:13 -05:00
jeremystretch
9c5891f1b6 Fixes #10929: Raise validation error when attempting to create a duplicate cable termination 2022-11-21 14:08:33 -05:00
jeremystretch
d5538c1ca3 Fixes #10241: Support referencing custom field related objects by attribute in addition to PK 2022-11-21 12:48:13 -05:00
jeremystretch
90f15b8d55 Fixes #10938: render_field template tag should respect label kwarg 2022-11-21 09:49:30 -05:00
jeremystretch
4e27e8d3dd Fixes #10969: Update cable paths ending at associated rear port when creating new front ports 2022-11-21 09:44:08 -05:00
jeremystretch
150cb772fe Fixes #10984: Fix navigation menu expansion for plugin menus comprising multiple words 2022-11-21 08:38:44 -05:00
jeremystretch
e494d7bb22 Fixes #10982: Catch NoReverseMatch exception when rendering tabs with no registered URL 2022-11-21 08:06:12 -05:00
jeremystretch
9774bb46ce Fixes #10973: Fix device links in VDC table 2022-11-18 16:33:06 -05:00
jeremystretch
84c0c45da9 Fixes #10980: Fix view tabs for plugin objects 2022-11-18 16:26:08 -05:00
jeremystretch
46e3883f19 Closes #815: Enable specifying terminations when bulk importing circuits 2022-11-18 15:22:24 -05:00
Arthur
3a89a676cd 10869 convert docstring to comment 2022-11-18 13:47:55 -05:00
jeremystretch
0885333b11 Fixes #9223: Fix serialization of array field values in change log 2022-11-18 11:24:14 -05:00
jeremystretch
c287641363 Changelog for #10236, #10653 2022-11-18 11:23:30 -05:00
Arthur Hanson
de9646d096 10653 log failed login attempts on INFO (#10843)
* 10653 log failed login attempts on INFO

* 10653 use signal to log failed login attempts

* 10653 use signal to log failed login attempts

* Update netbox/users/signals.py

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

* Update netbox/users/apps.py

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

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-11-18 08:57:57 -05:00
Arthur Hanson
dd2520d675 10236 fix device detail for power-feed (#10961)
* 10236 fix device detail for power-feed

* 10236 optimize with statement
2022-11-18 08:55:28 -05:00
jeremystretch
3a5914827b Fixes #6389: Call snapshot() on object when processing deletions 2022-11-17 21:04:55 -05:00
jeremystretch
cf55e96241 Fixes #10721: Disable ordering by custom object field columns 2022-11-17 16:30:54 -05:00
jeremystretch
bd29d15814 Fixes #10579: Mark cable traces terminating to a provider network as complete 2022-11-17 16:08:29 -05:00
jeremystretch
d3911e2a4c Fixes #9878: Fix spurious error message when rendering REST API docs 2022-11-17 15:13:37 -05:00
jeremystretch
eb591731ef #10712: Remove pin for swagger-spec-validator (fixed in v3.0.3) 2022-11-17 13:06:51 -05:00
jeremystretch
ae11419045 Changelog for #10946, #10948 2022-11-17 12:41:24 -05:00
jeremystretch
43bbd42d3c Fixes #10957: Add missing VDCs column to interface tables 2022-11-17 12:29:30 -05:00
jeremystretch
d4a231585a Clean up tests 2022-11-17 10:50:05 -05:00
kkthxbye-code
977b79ecee Check that device has a platform set before rendering napalm tab 2022-11-17 08:25:06 -05:00
kkthxbye-code
5202d0add9 Linkify primary IP for VDC 2022-11-17 08:22:42 -05:00
jeremystretch
ebf555e1fb Use strings to specify prerequisite models 2022-11-16 17:22:09 -05:00
jeremystretch
f411c4f439 Introduce panel template for services 2022-11-16 16:52:35 -05:00
jeremystretch
216d8d24b8 Add note to update model's SearchIndex 2022-11-16 16:40:01 -05:00
jeremystretch
cb2b256934 Fix typo 2022-11-16 16:38:29 -05:00
168 changed files with 2493 additions and 1134 deletions

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django
graphene_django<3.0
graphene_django
# WSGI HTTP server
# https://gunicorn.org/

View File

@@ -58,9 +58,11 @@ The following model fields support configurable choices:
* `circuits.Circuit.status`
* `dcim.Device.status`
* `dcim.Location.status`
* `dcim.Module.status`
* `dcim.PowerFeed.status`
* `dcim.Rack.status`
* `dcim.Site.status`
* `dcim.VirtualDeviceContext.status`
* `extras.JournalEntry.kind`
* `ipam.IPAddress.status`
* `ipam.IPRange.status`
@@ -68,6 +70,7 @@ The following model fields support configurable choices:
* `ipam.VLAN.status`
* `virtualization.Cluster.status`
* `virtualization.VirtualMachine.status`
* `wireless.WirelessLAN.status`
The following colors are supported:

View File

@@ -141,6 +141,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
## QUEUE_MAPPINGS
Allows changing which queues are used internally for background tasks.
```python
QUEUE_MAPPINGS = {
'webhook': 'low',
'report': 'high',
'script': 'high',
}
```
If no queue is defined the queue named `default` will be used.
---
## RELEASE_CHECK_URL
Default: None (disabled)

View File

@@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `USERNAME` - Redis username (if set)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
@@ -75,6 +76,7 @@ REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'USERNAME': 'netbox'
'PASSWORD': 'foobar',
'DATABASE': 0,
'SSL': False,
@@ -82,6 +84,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': ''
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@@ -137,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
## LOGOUT_REDIRECT_URL
Default: `'home'`
The view name or URL to which a user is redirected after logging out.
---
## SESSION_COOKIE_NAME
Default: `sessionid`

View File

@@ -12,6 +12,17 @@ BASE_PATH = 'netbox/'
---
## DEFAULT_LANGUAGE
Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
---
## DOCS_ROOT
Default: `$INSTALL_ROOT/docs/`
@@ -54,6 +65,14 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
## ENABLE_LOCALIZATION
Default: False
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
---
## HTTP_PROXIES
Default: None

View File

@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined.
active = DeviceStatusChoices.STATUS_ACTIVE
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
if console_port.connected_endpoint is None:
if not console_port.connected_endpoints:
self.log_failure(
console_port.device,
"No console connection defined for {}".format(console_port.name)
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:
if power_port.connected_endpoints:
connected_ports += 1
if not power_port.path.is_active:
self.log_warning(

View File

@@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
## 8. Update the UI templates
## 8. Update the SearchIndex
Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
## 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 9. Create/extend test cases
## 10. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
@@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
## 10. Update the model's documentation
## 11. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
!!! warning
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
```no-highlight
sudo /opt/netbox/upgrade.sh
```

View File

@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
### Status
The module's operational status.
!!! tip
Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Serial Number
The unique physical serial number assigned to this module by its manufacturer.

View File

@@ -73,6 +73,10 @@ The maximum depth of a mounted device that the rack can accommodate, in millimet
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

View File

@@ -1,6 +1,6 @@
# Branches
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes.
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
## Fields

View File

@@ -1,6 +1,55 @@
# NetBox v3.3
## v3.3.9 (FUTURE)
## v3.3.10 (2022-12-13)
### Enhancements
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
### Bug Fixes
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
---
## v3.3.9 (2022-11-30)
### Enhancements
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
### Bug Fixes
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
---
@@ -429,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
* Added the optional `device` field
* Added the `l2vpn_termination` read-only field
wireless.WirelessLAN
* wireless.WirelessLAN
* Added `tenant` field
wireless.WirelessLink
* wireless.WirelessLink
* Added `tenant` field

View File

@@ -1,22 +1,65 @@
# NetBox v3.4
## v3.4.2 (2023-01-03)
### Enhancements
* [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items
* [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search
* [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view
* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI
* [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name
* [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list
* [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled
* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration
### Bug Fixes
* [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned
* [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration
* [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search
* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components
* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules
---
## v3.4.1 (2022-12-16)
### Enhancements
* [#9971](https://github.com/netbox-community/netbox/issues/9971) - Enable ordering of nested group models by name
* [#11214](https://github.com/netbox-community/netbox/issues/11214) - Introduce the `DEFAULT_LANGUAGE` configuration parameter
### Bug Fixes
* [#11175](https://github.com/netbox-community/netbox/issues/11175) - Fix cloning of fields containing special characters
* [#11178](https://github.com/netbox-community/netbox/issues/11178) - Pressing enter in quick search box should not trigger bulk operations
* [#11184](https://github.com/netbox-community/netbox/issues/11184) - Correct visualization of cable path which splits across multiple circuit terminations
* [#11185](https://github.com/netbox-community/netbox/issues/11185) - Fix TemplateSyntaxError when viewing custom script results
* [#11189](https://github.com/netbox-community/netbox/issues/11189) - Fix localization of dates & numbers
* [#11205](https://github.com/netbox-community/netbox/issues/11205) - Correct cloning behavior for recursively-nested models
* [#11206](https://github.com/netbox-community/netbox/issues/11206) - Avoid clearing assigned groups if `REMOTE_AUTH_DEFAULT_GROUPS` is invalid
---
## v3.4.0 (2022-12-14)
!!! warning "PostgreSQL 11 Required"
NetBox v3.4 requires PostgreSQL 11 or later.
### Breaking Changes
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead.
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" within the same site will raise a validation error.
* The `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the ASN and contact models introduced in NetBox v3.1 prior to upgrading.
* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object.
* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead.
* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5.
### New Features
#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much speedier, more intelligent search capability. Matches are returned in order of precedence regardless of object type, and matched field values are highlighted in the results. Additionally, custom field values are now included in global search results (when enabled). Plugins can also register their own models with the new global search engine.
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine.
#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
@@ -24,19 +67,31 @@ A new model representing virtual device contexts (VDCs) has been added. VDCs are
#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse as a convenience. Saved filters can be kept private, or shared among NetBox users.
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string
### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
```
?status=active&region_id=12&tenant=acme
```
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well.
can be saved and applied to future queries as
#### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961))
```
?filter=my-custom-filter
```
Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches.
#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user.
#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects.
#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
Reports and custom scripts can now be scheduled for execution at a desired time.
Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours).
#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
@@ -47,6 +102,7 @@ This release introduces a new programmatic API that enables plugins and custom s
### Enhancements
* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search
* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
@@ -59,23 +115,40 @@ This release introduces a new programmatic API that enables plugins and custom s
* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules
* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns
* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN
* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts
* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
### Bug Fixes (from v3.4-beta1)
* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned
* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs
* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs
* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables
* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table
* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects
* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL
* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words
* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data
* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search
* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search
* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API
### Plugins API
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method
* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps via `django_apps` attribute
* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function
@@ -85,11 +158,13 @@ This release introduces a new programmatic API that enables plugins and custom s
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk
* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views
* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo
* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization
@@ -104,41 +179,39 @@ This release introduces a new programmatic API that enables plugins and custom s
* dcim.Device
* Added a `description` field
* dcim.DeviceType
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `weight`, and `weight_unit` fields
* dcim.Module
* Added a `description` field
* dcim.Interface
* Added the `vdcs` field
* dcim.Module
* Added a required `status` field
* dcim.ModuleType
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `weight`, and `weight_unit` fields
* dcim.PowerFeed
* Added a `description` field
* dcim.PowerPanel
* Added `description` and `comments` fields
* dcim.Rack
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields
* dcim.RackReservation
* Added a `comments` field
* dcim.VirtualChassis
* Added `description` and `comments` fields
* extras.CustomField
* Added the `search_weight` field
* Added a `search_weight` field
* extras.CustomLink
* Renamed `content_type` field to `content_types`
* extras.ExportTemplate
* Renamed `content_type` field to `content_types`
* extras.JobResult
* Added `scheduled` and `started` datetime fields
* Added `interval`, `scheduled`, and `started` fields
* ipam.Aggregate
* Added a `comments` field
* ipam.ASN
* Added a `comments` field
* ipam.FHRPGroup
* Added a `comments` field
* Added optional `name` field
* Added `name` and `comments` fields
* ipam.IPAddress
* Added a `comments` field
* ipam.IPRange

View File

@@ -1,12 +1,16 @@
from django import forms
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
__all__ = (
'CircuitImportForm',
'CircuitTerminationImportForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderNetworkImportForm',
@@ -76,3 +80,23 @@ class CircuitImportForm(NetBoxModelImportForm):
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description', 'comments', 'tags'
]
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
required=False
)
provider_network = CSVModelChoiceField(
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description',
]

View File

@@ -145,16 +145,28 @@ class CircuitTerminationForm(NetBoxModelForm):
},
required=False
)
provider_network_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
label='Provider',
initial_params={
'networks': 'provider_network'
}
)
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
query_params={
'provider_id': '$provider_network_provider',
},
required=False
)
class Meta:
model = CircuitTermination
fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'tags',
]
help_texts = {
'port_speed': _("Physical circuit speed"),

View File

@@ -104,6 +104,10 @@ class Circuit(PrimaryModel):
clone_fields = (
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
)
prerequisite_models = (
'circuits.CircuitType',
'circuits.Provider',
)
class Meta:
ordering = ['provider', 'cid']
@@ -117,10 +121,6 @@ class Circuit(PrimaryModel):
def __str__(self):
return self.cid
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('circuits.Provider'), CircuitType]
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])

View File

@@ -108,6 +108,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit
def setUp(self):
super().setUp()
self.add_permissions(
'circuits.add_circuittermination',
)
@classmethod
def setUpTestData(cls):

View File

@@ -233,6 +233,16 @@ class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
table = tables.CircuitTable
additional_permissions = [
'circuits.add_circuittermination',
]
related_object_forms = {
'terminations': forms.CircuitTerminationImportForm,
}
def prep_related_object_data(self, parent, data):
data.update({'circuit': parent})
return data
class CircuitBulkEditView(generic.BulkEditView):

View File

@@ -210,9 +210,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack
fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'powerfeed_count',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
@@ -680,11 +680,14 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
# Related object counts
interface_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualDeviceContext
fields = [
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
]
@@ -693,12 +696,13 @@ class ModuleSerializer(NetBoxModelSerializer):
device = NestedDeviceSerializer()
module_bay = NestedModuleBaySerializer()
module_type = NestedModuleTypeSerializer()
status = ChoiceField(choices=ModuleStatusChoices, required=False)
class Meta:
model = Module
fields = [
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -541,6 +541,8 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(
'device__device_type', 'device', 'tenant', 'tags',
).annotate(
interface_count=count_related(Interface, 'vdcs'),
)
serializer_class = serializers.VirtualDeviceContextSerializer
filterset_class = filtersets.VirtualDeviceContextFilterSet

View File

@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
TYPE_4POST = '4-post-frame'
TYPE_CABINET = '4-post-cabinet'
TYPE_WALLFRAME = 'wall-frame'
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
TYPE_WALLCABINET = 'wall-cabinet'
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
CHOICES = (
(TYPE_2POST, '2-post frame'),
(TYPE_4POST, '4-post frame'),
(TYPE_CABINET, '4-post cabinet'),
(TYPE_WALLFRAME, 'Wall-mounted frame'),
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
)
@@ -194,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet):
)
#
# Modules
#
class ModuleStatusChoices(ChoiceSet):
key = 'Module.status'
STATUS_OFFLINE = 'offline'
STATUS_ACTIVE = 'active'
STATUS_PLANNED = 'planned'
STATUS_STAGED = 'staged'
STATUS_FAILED = 'failed'
STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGED, 'Staged', 'blue'),
(STATUS_FAILED, 'Failed', 'red'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
]
#
# ConsolePorts
#

View File

@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
def to_python(self, value):
if value is None:
return value
if type(value) is str:
value = value.replace(' ', '')
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError:

View File

@@ -322,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
]
def search(self, queryset, name, value):
@@ -1082,18 +1082,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
queryset=Device.objects.all(),
label=_('Device (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices,
null_value=None
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
class Meta:
model = Module
fields = ['id', 'asset_tag']
fields = ['id', 'status', 'asset_tag']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)

View File

@@ -294,6 +294,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
min_value=0,
required=False
)
max_weight = forms.IntegerField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
@@ -316,11 +320,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
('Hardware', (
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
)),
('Weight', ('weight', 'weight_unit')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
)
nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'weight_unit', 'description', 'comments',
'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -574,6 +578,12 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
'manufacturer_id': '$manufacturer'
}
)
status = forms.ChoiceField(
choices=add_blank_choice(ModuleStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
serial = forms.CharField(
max_length=50,
required=False,
@@ -590,7 +600,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
model = Module
fieldsets = (
(None, ('manufacturer', 'module_type', 'serial', 'description')),
(None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
)
nullable_fields = ('serial', 'description', 'comments')
@@ -1321,7 +1331,7 @@ class FrontPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class RearPortBulkEditForm(
@@ -1332,7 +1342,7 @@ class RearPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class ModuleBayBulkEditForm(

View File

@@ -14,6 +14,7 @@ from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
__all__ = (
'CableImportForm',
@@ -195,13 +196,18 @@ class RackImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for rack weights')
)
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -437,24 +443,40 @@ class DeviceImportForm(BaseDeviceImportForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class ModuleImportForm(NetBoxModelImportForm):
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
to_field_name='name',
help_text=_('The device in which this module is installed')
)
module_bay = CSVModelChoiceField(
queryset=ModuleBay.objects.all(),
to_field_name='name'
to_field_name='name',
help_text=_('The module bay in which this module is installed')
)
module_type = CSVModelChoiceField(
queryset=ModuleType.objects.all(),
to_field_name='model'
to_field_name='model',
help_text=_('The type of module')
)
status = CSVChoiceField(
choices=ModuleStatusChoices,
help_text=_('Operational status')
)
replicate_components = forms.BooleanField(
required=False,
help_text=_('Automatically populate components associated with this module type (enabled by default)')
)
adopt_components = forms.BooleanField(
required=False,
help_text=_('Adopt already existing components')
)
class Meta:
model = Module
fields = (
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags',
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
'replicate_components', 'adopt_components', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -465,6 +487,13 @@ class ModuleImportForm(NetBoxModelImportForm):
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
def clean_replicate_components(self):
# Make sure replicate_components is True when it's not included in the uploaded data
if 'replicate_components' not in self.data:
return True
else:
return self.cleaned_data['replicate_components']
class ChildDeviceImportForm(BaseDeviceImportForm):
parent = CSVModelChoiceField(
@@ -856,12 +885,22 @@ class InventoryItemImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Parent inventory item')
)
component_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
required=False,
help_text=_('Component Type')
)
component_name = forms.CharField(
required=False,
help_text=_('Component Name')
)
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags'
'description', 'tags', 'component_type', 'component_name',
)
def __init__(self, *args, **kwargs):
@@ -879,6 +918,24 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
#
# Device component roles

View File

@@ -6,6 +6,7 @@ from dcim.constants import *
__all__ = (
'InterfaceCommonForm',
'ModuleCommonForm'
)
@@ -48,3 +49,62 @@ class InterfaceCommonForm(forms.Form):
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
f"the interface's parent device/VM, or they must be global"
})
class ModuleCommonForm(forms.Form):
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get('replicate_components')
adopt_components = self.cleaned_data.get('adopt_components')
device = self.cleaned_data.get('device')
module_type = self.cleaned_data.get('module_type')
module_bay = self.cleaned_data.get('module_bay')
if adopt_components:
self.instance._adopt_components = True
# Bail out if we are not installing a new module or if we are not replicating components (or if
# validation has already failed)
if self.errors or self.instance.pk or not replicate_components:
self.instance._disable_replication = True
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)

View File

@@ -229,7 +229,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'weight_unit')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -284,7 +284,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
)
tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
required=False,
min_value=1
)
max_weight = forms.IntegerField(
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
@@ -763,7 +768,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -780,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
label=_('Type'),
fetch_trigger='open'
)
status = MultipleChoiceField(
choices=ModuleStatusChoices,
required=False
)
serial = forms.CharField(
required=False
)

View File

@@ -17,7 +17,7 @@ from utilities.forms import (
)
from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm
from .common import InterfaceCommonForm, ModuleCommonForm
__all__ = (
'CableForm',
@@ -279,7 +279,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
]
help_texts = {
'site': _("The site at which the rack exists"),
@@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
class ModuleForm(NetBoxModelForm):
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
initial_params={
@@ -703,7 +703,7 @@ class ModuleForm(NetBoxModelForm):
fieldsets = (
('Module', (
'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags',
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
)),
('Hardware', (
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
@@ -713,7 +713,7 @@ class ModuleForm(NetBoxModelForm):
class Meta:
model = Module
fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
'replicate_components', 'adopt_components', 'description', 'comments',
]
@@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
self.fields['adopt_components'].initial = False
self.fields['adopt_components'].disabled = True
def save(self, *args, **kwargs):
# If replicate_components is False, disable automatic component replication on the instance
if self.instance.pk or not self.cleaned_data['replicate_components']:
self.instance._disable_replication = True
if self.cleaned_data['adopt_components']:
self.instance._adopt_components = True
return super().save(*args, **kwargs)
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get("replicate_components")
adopt_components = self.cleaned_data.get("adopt_components")
device = self.cleaned_data['device']
module_type = self.cleaned_data['module_type']
module_bay = self.cleaned_data['module_bay']
# Bail out if we are not installing a new module or if we are not replicating components
if self.instance.pk or not replicate_components:
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)
class CableForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
@@ -1462,7 +1400,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('VRF')
)
wwn = forms.CharField(
empty_value=None,
required=False,
@@ -1470,9 +1407,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
)
fieldsets = (
('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
@@ -1612,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm):
queryset=Manufacturer.objects.all(),
required=False
)
component_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
# Assigned component selectors
consoleport = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console port')
)
component_id = forms.IntegerField(
consoleserverport = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console server port')
)
frontport = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Front port')
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Interface')
)
poweroutlet = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power outlet')
)
powerport = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power port')
)
rearport = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Rear port')
)
fieldsets = (
@@ -1632,11 +1617,57 @@ class InventoryItemForm(DeviceComponentForm):
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'component_type', 'component_id', 'tags',
'description', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selectin
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
# Specifically allow editing the device of IntentoryItems
if self.instance.pk:
self.fields['device'].disabled = False
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
#
# Device component roles
#
@@ -1705,6 +1736,24 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
'rack_id': '$rack',
}
)
primary_ip4 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv4',
required=False,
query_params={
'device_id': '$device',
'family': '4',
}
)
primary_ip6 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv6',
required=False,
query_params={
'device_id': '$device',
'family': '6',
}
)
fieldsets = (
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),

View File

@@ -1,5 +1,3 @@
# Generated by Django 4.0.7 on 2022-09-23 01:01
from django.db import migrations, models
@@ -10,11 +8,8 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Device types
migrations.AddField(
model_name='devicetype',
name='weight',
@@ -26,10 +21,12 @@ class Migration(migrations.Migration):
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='moduletype',
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Module types
migrations.AddField(
model_name='moduletype',
name='weight',
@@ -41,18 +38,35 @@ class Migration(migrations.Migration):
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
model_name='moduletype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Racks
migrations.AddField(
model_name='rack',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='rack',
name='max_weight',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='_abs_max_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0163_rack_devicetype_moduletype_weights'),
('dcim', '0163_weight_fields'),
]
operations = [

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.2 on 2022-12-09 15:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0166_virtualdevicecontext'),
]
operations = [
migrations.AddField(
model_name='module',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -279,6 +279,17 @@ class CableTermination(models.Model):
def clean(self):
super().clean()
# Check for existing termination
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
termination_type=self.termination_type,
termination_id=self.termination_id
).first()
if existing_termination is not None:
raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
f"{self.termination_id}: cable {existing_termination.cable.pk}"
)
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
@@ -556,11 +567,12 @@ class CablePath(models.Model):
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
if len(remote_terminations) > 1:
is_split = True
break
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
@@ -570,6 +582,7 @@ class CablePath(models.Model):
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
is_complete = True
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
@@ -673,6 +686,7 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
from circuits.models import CircuitTermination
nodes = self.path_objects[-1]
# RearPort splitting to multiple FrontPorts with no stack position
@@ -682,3 +696,8 @@ class CablePath(models.Model):
# RearPorts connected to different cables
elif type(nodes[0]) is FrontPort:
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
# Cable terminating to multiple CircuitTerminations
elif type(nodes[0]) is CircuitTermination:
return [
ct.get_peer_termination() for ct in nodes
]

View File

@@ -197,7 +197,7 @@ class PathEndpoint(models.Model):
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
"""
_path = models.ForeignKey(
to='dcim.CablePath',
@@ -1129,3 +1129,25 @@ class InventoryItem(MPTTModel, ComponentModel):
raise ValidationError({
"parent": "Cannot assign self as parent."
})
# Validation for moving InventoryItems
if self.pk:
# Cannot move an InventoryItem to another device if it has a parent
if self.parent and self.parent.device != self.device:
raise ValidationError({
"parent": "Parent inventory item does not belong to the same device."
})
# Prevent moving InventoryItems with children
first_child = self.get_children().first()
if first_child and first_child.device != self.device:
raise ValidationError("Cannot move an inventory item with dependent children")
# When moving an InventoryItem to another device, remove any associated component
if self.component and self.component.device != self.device:
self.component = None
else:
if self.component and self.component.device != self.device:
raise ValidationError({
"device": "Cannot assign inventory item to component on another device"
})

View File

@@ -3,7 +3,6 @@ import yaml
from functools import cached_property
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -124,6 +123,9 @@ class DeviceType(PrimaryModel, WeightMixin):
clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ['manufacturer', 'model']
@@ -151,10 +153,6 @@ class DeviceType(PrimaryModel, WeightMixin):
self._original_front_image = self.front_image
self._original_rear_image = self.rear_image
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@@ -325,6 +323,9 @@ class ModuleType(PrimaryModel, WeightMixin):
)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ('manufacturer', 'model')
@@ -338,10 +339,6 @@ class ModuleType(PrimaryModel, WeightMixin):
def __str__(self):
return self.model
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk])
@@ -599,6 +596,11 @@ class Device(PrimaryModel, ConfigContextModel):
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'cluster', 'virtual_chassis',
)
prerequisite_models = (
'dcim.Site',
'dcim.DeviceRole',
'dcim.DeviceType',
)
class Meta:
ordering = ('_name', 'pk') # Name may be null
@@ -638,10 +640,6 @@ class Device(PrimaryModel, ConfigContextModel):
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@@ -927,6 +925,11 @@ class Module(PrimaryModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='instances'
)
status = models.CharField(
max_length=50,
choices=ModuleStatusChoices,
default=ModuleStatusChoices.STATUS_ACTIVE
)
serial = models.CharField(
max_length=50,
blank=True,
@@ -941,7 +944,7 @@ class Module(PrimaryModel, ConfigContextModel):
help_text=_('A unique tag used to identify this device')
)
clone_fields = ('device', 'module_type')
clone_fields = ('device', 'module_type', 'status')
class Meta:
ordering = ('module_bay',)
@@ -952,10 +955,13 @@ class Module(PrimaryModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:module', args=[self.pk])
def get_status_color(self):
return ModuleStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if self.module_bay.device != self.device:
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
raise ValidationError(
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
)
@@ -1176,3 +1182,20 @@ class VirtualDeviceContext(PrimaryModel):
return self.primary_ip4
else:
return None
def clean(self):
super().clean()
# Validate primary IPv4/v6 assignment
for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)):
if not primary_ip:
continue
if primary_ip.family != family:
raise ValidationError({
f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
})
device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces:
raise ValidationError({
f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
})

View File

@@ -39,7 +39,5 @@ class WeightMixin(models.Model):
super().clean()
# Validate weight and weight_unit
if self.weight is not None and not self.weight_unit:
if self.weight and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a weight")
elif self.weight is None:
self.weight_unit = ''

View File

@@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -48,6 +47,10 @@ class PowerPanel(PrimaryModel):
to='extras.ImageAttachment'
)
prerequisite_models = (
'dcim.Site',
)
class Meta:
ordering = ['site', 'name']
constraints = (
@@ -60,10 +63,6 @@ class PowerPanel(PrimaryModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
@@ -137,6 +136,9 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization',
)
prerequisite_models = (
'dcim.PowerPanel',
)
class Meta:
ordering = ['power_panel', 'name']
@@ -150,10 +152,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [PowerPanel, ]
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])

View File

@@ -1,7 +1,6 @@
import decimal
from functools import cached_property
from django.apps import apps
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
@@ -18,7 +17,7 @@ from dcim.svg import RackElevationSVG
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange
from utilities.utils import array_to_string, drange, to_grams
from .device_components import PowerPort
from .devices import Device, Module
from .mixins import WeightMixin
@@ -150,6 +149,16 @@ class Rack(PrimaryModel, WeightMixin):
choices=RackDimensionUnitChoices,
blank=True,
)
max_weight = models.PositiveIntegerField(
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
mounting_depth = models.PositiveSmallIntegerField(
blank=True,
null=True,
@@ -175,7 +184,10 @@ class Rack(PrimaryModel, WeightMixin):
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Site',
)
class Meta:
@@ -197,10 +209,6 @@ class Rack(PrimaryModel, WeightMixin):
return f'{self.name} ({self.facility_id})'
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@@ -217,6 +225,10 @@ class Rack(PrimaryModel, WeightMixin):
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a maximum weight")
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(
@@ -239,6 +251,16 @@ class Rack(PrimaryModel, WeightMixin):
'location': f"Location must be from the same site, {self.site}."
})
def save(self, *args, **kwargs):
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
else:
self._abs_max_weight = None
super().save(*args, **kwargs)
@property
def units(self):
"""
@@ -488,16 +510,17 @@ class RackReservation(PrimaryModel):
max_length=200
)
clone_fields = ('rack', 'user', 'tenant')
prerequisite_models = (
'dcim.Rack',
)
class Meta:
ordering = ['created', 'pk']
def __str__(self):
return "Reservation for rack {}".format(self.rack)
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), Rack, ]
def get_absolute_url(self):
return reverse('dcim:rackreservation', args=[self.pk])

View File

@@ -286,6 +286,9 @@ class Location(NestedGroupModel):
)
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
prerequisite_models = (
'dcim.Site',
)
class Meta:
ordering = ['site', 'name']
@@ -312,10 +315,6 @@ class Location(NestedGroupModel):
),
)
@classmethod
def get_prerequisite_models(cls):
return [Site, ]
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])

View File

@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()
@receiver(post_save, sender=FrontPort)
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
"""
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
"""
if created and not raw:
rearport = instance.rear_port
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
cablepath.retrace()

View File

@@ -139,7 +139,8 @@ class PlatformTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
region = tables.Column(
@@ -220,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
name = tables.TemplateColumn(
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
site = tables.Column(
@@ -504,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name='Tagged VLANs'
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column(
@@ -521,6 +526,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
orderable=False,
verbose_name='Wireless LANs'
)
vdcs = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='VDCs'
)
vrf = tables.Column(
linkify=True
)
@@ -534,7 +543,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -568,7 +577,7 @@ class DeviceInterfaceTable(InterfaceTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions',
)
order_by = ('name',)
@@ -893,7 +902,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
)
device = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
primary_ip = tables.Column(
@@ -909,6 +919,11 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
linkify=True,
verbose_name='IPv6 Address'
)
interface_count = columns.LinkedCountColumn(
viewname='dcim:interface_list',
url_params={'vdc_id': 'pk'},
verbose_name='Interfaces'
)
comments = columns.MarkdownColumn()
@@ -919,8 +934,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.VirtualDeviceContext
fields = (
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4',
'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',

View File

@@ -3,7 +3,7 @@ import django_tables2 as tables
from dcim import models
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
__all__ = (
'ConsolePortTemplateTable',
@@ -49,7 +49,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
model = models.Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'contacts', 'actions', 'created', 'last_updated',
'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
@@ -84,7 +84,7 @@ class DeviceTypeTable(NetBoxTable):
template_code='{{ value|floatformat }}'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from dcim.models import Module, ModuleType
from netbox.tables import NetBoxTable, columns
from .template_code import DEVICE_WEIGHT
from .template_code import WEIGHT
__all__ = (
'ModuleTable',
@@ -28,7 +28,7 @@ class ModuleTypeTable(NetBoxTable):
url_name='dcim:moduletype_list'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
@@ -56,6 +56,7 @@ class ModuleTable(NetBoxTable):
module_type = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:module_list'
@@ -64,9 +65,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description',
'comments', 'tags',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags',
)
default_columns = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
)

View File

@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT
from .template_code import WEIGHT
__all__ = (
'RackTable',
@@ -81,17 +81,21 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name='Outer Depth'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
max_weight = columns.TemplateColumn(
template_code=WEIGHT,
order_by=('_abs_max_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags',
'created', 'last_updated',
'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description',
'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
'contacts', 'tags', 'created', 'last_updated', 'actions',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

View File

@@ -15,15 +15,13 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
DEVICE_WEIGHT = """
WEIGHT = """
{% load helpers %}
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
</a>
{{ value|default:'<span class="badge bg-info">Unnamed device</span>' }}
"""
DEVICEBAY_STATUS = """

View File

@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'ABC123',
'asset_tag': 'Foo1',
},
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[4].pk,
'module_type': module_types[1].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'DEF456',
'asset_tag': 'Foo2',
},
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[5].pk,
'module_type': module_types[2].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'GHI789',
'asset_tag': 'Foo3',
},
@@ -1954,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase):
class ConnectedDeviceTest(APITestCase):
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
devices = (
Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site),
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='eth0'),
Interface(device=devices[1], name='eth0'),
Interface(device=devices[0], name='eth1'), # Not connected
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
Interface.objects.bulk_create(interfaces)
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
url_params = f'?peer_device=TestDevice1&peer_interface=eth0'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device2.name)
self.assertEqual(response.data['name'], 'TestDevice2')
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
url_params = f'?peer_device=TestDevice1&peer_interface=eth1'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)

View File

@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1
cable1.delete()

View File

@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
Rack.objects.bulk_create(racks)
@@ -521,6 +521,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_max_weight(self):
params = {'max_weight': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1876,15 +1880,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
)
Module.objects.bulk_create(modules)
@@ -1912,6 +1916,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_status(self):
params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self):
params = {'serial': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -73,7 +73,8 @@ class LocationTestCase(TestCase):
class RackTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
@@ -240,30 +241,31 @@ class RackTestCase(TestCase):
class DeviceTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.device_type = DeviceType.objects.create(
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.device_role = DeviceRole.objects.create(
device_role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
# Create DeviceType components
ConsolePortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Console Port 1'
).save()
ConsoleServerPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
@@ -271,21 +273,21 @@ class DeviceTestCase(TestCase):
ppt.save()
PowerOutletTemplate(
device_type=self.device_type,
device_type=device_type,
name='Power Outlet 1',
power_port=ppt,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
InterfaceTemplate(
device_type=self.device_type,
device_type=device_type,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
).save()
rpt = RearPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
@@ -293,7 +295,7 @@ class DeviceTestCase(TestCase):
rpt.save()
FrontPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rpt,
@@ -301,12 +303,12 @@ class DeviceTestCase(TestCase):
).save()
ModuleBayTemplate(
device_type=self.device_type,
device_type=device_type,
name='Module Bay 1'
).save()
DeviceBayTemplate(
device_type=self.device_type,
device_type=device_type,
name='Device Bay 1'
).save()
@@ -315,9 +317,9 @@ class DeviceTestCase(TestCase):
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
d.save()
@@ -381,9 +383,9 @@ class DeviceTestCase(TestCase):
def test_multiple_unnamed_devices(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name=None
)
device1.save()
@@ -402,9 +404,9 @@ class DeviceTestCase(TestCase):
def test_device_name_case_sensitivity(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='device 1'
)
device1.save()
@@ -423,9 +425,9 @@ class DeviceTestCase(TestCase):
def test_device_duplicate_names(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
device1.save()
@@ -459,7 +461,8 @@ class DeviceTestCase(TestCase):
class CableTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -469,72 +472,76 @@ class CableTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
self.cable.save()
interface1 = Interface.objects.create(device=device1, name='eth0')
interface2 = Interface.objects.create(device=device2, name='eth0')
interface3 = Interface.objects.create(device=device2, name='eth1')
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
self.patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
power_port1 = PowerPort.objects.create(device=device2, name='psu1')
patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site
)
self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
self.front_port1 = FrontPort.objects.create(
device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c')
front_port1 = FrontPort.objects.create(
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1
)
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
self.front_port2 = FrontPort.objects.create(
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2)
front_port2 = FrontPort.objects.create(
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1
)
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
self.front_port3 = FrontPort.objects.create(
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3)
front_port3 = FrontPort.objects.create(
device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
)
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
self.front_port4 = FrontPort.objects.create(
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
front_port4 = FrontPort.objects.create(
device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
)
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider)
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A')
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z')
self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A')
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
def test_cable_creation(self):
"""
When a new Cable is created, it must be cached on either termination point.
"""
self.interface1.refresh_from_db()
self.interface2.refresh_from_db()
self.assertEqual(self.interface1.cable, self.cable)
self.assertEqual(self.interface2.cable, self.cable)
self.assertEqual(self.interface1.cable_end, 'A')
self.assertEqual(self.interface2.cable_end, 'B')
self.assertEqual(self.interface1.link_peers, [self.interface2])
self.assertEqual(self.interface2.link_peers, [self.interface1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
cable = Cable.objects.first()
self.assertEqual(interface1.cable, cable)
self.assertEqual(interface2.cable, cable)
self.assertEqual(interface1.cable_end, 'A')
self.assertEqual(interface2.cable_end, 'B')
self.assertEqual(interface1.link_peers, [interface2])
self.assertEqual(interface2.link_peers, [interface1])
def test_cable_deletion(self):
"""
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
should still return the PK of the string even after being nullified.
"""
self.cable.delete()
self.assertIsNone(self.cable.pk)
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
cable = Cable.objects.first()
cable.delete()
self.assertIsNone(cable.pk)
self.assertNotEqual(str(cable), '#None')
interface1 = Interface.objects.get(pk=interface1.pk)
self.assertIsNone(interface1.cable)
self.assertListEqual(interface1.link_peers, [])
interface2 = Interface.objects.get(pk=self.interface2.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
self.assertIsNone(interface2.cable)
self.assertListEqual(interface2.link_peers, [])
@@ -542,7 +549,10 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
"""
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -550,7 +560,11 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable are of the same type.
"""
cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -558,8 +572,11 @@ class CableTestCase(TestCase):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
# An interface cannot be connected to a power port, for example
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -567,7 +584,10 @@ class CableTestCase(TestCase):
"""
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
"""
cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
with self.assertRaises(ValidationError):
cable.clean()
@@ -575,8 +595,11 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
device1 = Device.objects.get(name='TestDevice1')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
with self.assertRaises(ValidationError):
cable.clean()
@@ -584,15 +607,19 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a wireless interface
"""
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
device1 = Device.objects.get(name='TestDevice1')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
with self.assertRaises(ValidationError):
cable.clean()
class VirtualDeviceContextTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -602,36 +629,41 @@ class VirtualDeviceContextTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
def test_vdc_and_interface_creation(self):
device = Device.objects.first()
vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc.full_clean()
vdc.save()
interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
interface.full_clean()
interface.save()
interface.vdcs.set([vdc])
def test_vdc_duplicate_name(self):
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
device = Device.objects.first()
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc1.full_clean()
vdc1.save()
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
with self.assertRaises(ValidationError):
vdc2.full_clean()
def test_vdc_duplicate_identifier(self):
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
device = Device.objects.first()
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc1.full_clean()
vdc1.save()
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
with self.assertRaises(ValidationError):
vdc2.full_clean()

View File

@@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
class NaturalOrderingTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
def test_interface_ordering_numeric(self):
device = Device.objects.first()
INTERFACES = [
'0',
'0.0',
@@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_linux(self):
device = Device.objects.first()
INTERFACES = [
'eth0',
'eth0.1',
@@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_junos(self):
device = Device.objects.first()
INTERFACES = [
'xe-0/0/0',
'xe-0/0/1',
@@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_ios(self):
device = Device.objects.first()
INTERFACES = [
'GigabitEthernet0/1',
'GigabitEthernet0/2',
@@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)

View File

@@ -17,6 +17,7 @@ from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from tenancy.models import Tenant
from utilities.choices import ImportFormatChoices
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -388,15 +389,18 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'weight': 100,
'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"site,location,name,status,width,u_height",
"Site 1,,Rack 4,active,19,42",
"Site 1,Location 1,Rack 5,active,19,42",
"Site 2,Location 2,Rack 6,active,19,42",
"site,location,name,status,width,u_height,weight,max_weight,weight_unit",
"Site 1,,Rack 4,active,19,42,100,2000,kg",
"Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
"Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
)
cls.csv_update_data = (
@@ -420,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 30,
'outer_depth': 30,
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
'weight': 200,
'max_weight': 4000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'New comments',
}
@@ -1887,26 +1894,28 @@ class ModuleTestCase(
'device': devices[0].pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'A',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'module_type': module_types[3].pk,
'status': ModuleStatusChoices.STATUS_PLANNED,
}
cls.csv_data = (
"device,module_bay,module_type,serial,asset_tag",
"Device 2,Module Bay 1,Module Type 1,A,A",
"Device 2,Module Bay 2,Module Type 2,B,B",
"Device 2,Module Bay 3,Module Type 3,C,C",
"device,module_bay,module_type,status,serial,asset_tag",
"Device 2,Module Bay 1,Module Type 1,active,A,A",
"Device 2,Module Bay 2,Module Type 2,planned,B,B",
"Device 2,Module Bay 3,Module Type 3,failed,C,C",
)
cls.csv_update_data = (
"id,serial",
f"{modules[0].pk},Serial 2",
f"{modules[1].pk},Serial 3",
f"{modules[2].pk},Serial 1",
"id,status,serial",
f"{modules[0].pk},offline,Serial 2",
f"{modules[1].pk},offline,Serial 3",
f"{modules[2].pk},offline,Serial 1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -1942,6 +1951,54 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_bulk_replication(self):
self.add_permissions('dcim.add_module')
# Add 5 InterfaceTemplates to a ModuleType
module_type = ModuleType.objects.first()
interface_templates = [
InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
for i in range(1, 6)
]
InterfaceTemplate.objects.bulk_create(interface_templates)
# Create a module *without* replicating components
device = Device.objects.get(name='Device 2')
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
csv_data = [
"device,module_bay,module_type,status,replicate_components",
f"{device.name},{module_bay.name},{module_type.model},active,false"
]
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = Module.objects.count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
# Create a second module (in the next bay) with replicated components
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = Module.objects.count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_adoption(self):
self.add_permissions('dcim.add_module')
@@ -1979,6 +2036,50 @@ class ModuleTestCase(
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_bulk_adoption(self):
self.add_permissions('dcim.add_module')
interface_name = "Interface-1"
# Add an interface to the ModuleType
module_type = ModuleType.objects.first()
InterfaceTemplate(module_type=module_type, name=interface_name).save()
form_data = self.form_data.copy()
device = Device.objects.get(pk=form_data['device'])
# Create an interface to be adopted
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
interface.save()
# Ensure that interface is created with no module
self.assertIsNone(interface.module)
# Create a module with adopted components
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
csv_data = [
"device,module_bay,module_type,status,replicate_components,adopt_components",
f"{device.name},{module_bay.name},{module_type.model},active,false,true"
]
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = self._get_queryset().count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
# Re-retrieve interface to get new module id
interface.refresh_from_db()
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort

View File

@@ -391,7 +391,7 @@ class SiteView(generic.ObjectView):
scope_id=instance.pk
).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
}
locations = Location.objects.add_related_count(
@@ -653,17 +653,18 @@ class RackElevationListView(generic.ObjectListView):
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Ordering
ORDERING_CHOICES = {
'name': 'Name (A-Z)',
'-name': 'Name (Z-A)',
'facility_id': 'Facility ID (A-Z)',
'-facility_id': 'Facility ID (Z-A)',
}
sort = request.GET.get('sort', "name")
sort = request.GET.get('sort', 'name')
if sort not in ORDERING_CHOICES:
sort = 'name'
racks = racks.order_by(sort)
sort_field = sort.replace("name", "_name") # Use natural ordering
racks = racks.order_by(sort_field)
# Pagination
per_page = get_paginate_count(request)
@@ -690,6 +691,7 @@ class RackElevationListView(generic.ObjectListView):
'sort_choices': ORDERING_CHOICES,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
'model': self.queryset.model,
})
@@ -952,6 +954,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(),
permission='dcim.view_consoleporttemplate',
weight=550,
hide_if_empty=True
)
@@ -966,6 +969,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(),
permission='dcim.view_consoleserverporttemplate',
weight=560,
hide_if_empty=True
)
@@ -980,6 +984,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(),
permission='dcim.view_powerporttemplate',
weight=570,
hide_if_empty=True
)
@@ -994,6 +999,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(),
permission='dcim.view_poweroutlettemplate',
weight=580,
hide_if_empty=True
)
@@ -1008,6 +1014,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(),
permission='dcim.view_interfacetemplate',
weight=520,
hide_if_empty=True
)
@@ -1022,6 +1029,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(),
permission='dcim.view_frontporttemplate',
weight=530,
hide_if_empty=True
)
@@ -1036,6 +1044,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(),
permission='dcim.view_rearporttemplate',
weight=540,
hide_if_empty=True
)
@@ -1050,6 +1059,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
label=_('Module Bays'),
badge=lambda obj: obj.modulebaytemplates.count(),
permission='dcim.view_modulebaytemplate',
weight=510,
hide_if_empty=True
)
@@ -1064,6 +1074,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
label=_('Device Bays'),
badge=lambda obj: obj.devicebaytemplates.count(),
permission='dcim.view_devicebaytemplate',
weight=500,
hide_if_empty=True
)
@@ -1078,6 +1089,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitemtemplates.count(),
permission='dcim.view_invenotryitemtemplate',
weight=590,
hide_if_empty=True
)
@@ -1180,6 +1192,7 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(),
permission='dcim.view_consoleporttemplate',
weight=530,
hide_if_empty=True
)
@@ -1194,6 +1207,7 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(),
permission='dcim.view_consoleserverporttemplate',
weight=540,
hide_if_empty=True
)
@@ -1208,6 +1222,7 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(),
permission='dcim.view_powerporttemplate',
weight=550,
hide_if_empty=True
)
@@ -1222,6 +1237,7 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(),
permission='dcim.view_poweroutlettemplate',
weight=560,
hide_if_empty=True
)
@@ -1236,6 +1252,7 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView):
label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(),
permission='dcim.view_interfacetemplate',
weight=500,
hide_if_empty=True
)
@@ -1250,6 +1267,7 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(),
permission='dcim.view_frontporttemplate',
weight=510,
hide_if_empty=True
)
@@ -1264,6 +1282,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(),
permission='dcim.view_rearporttemplate',
weight=520,
hide_if_empty=True
)
@@ -1872,6 +1891,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleports.count(),
permission='dcim.view_consoleport',
weight=550,
hide_if_empty=True
)
@@ -1886,6 +1906,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverports.count(),
permission='dcim.view_consoleserverport',
weight=560,
hide_if_empty=True
)
@@ -1900,6 +1921,7 @@ class DevicePowerPortsView(DeviceComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerports.count(),
permission='dcim.view_powerport',
weight=570,
hide_if_empty=True
)
@@ -1914,6 +1936,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlets.count(),
permission='dcim.view_poweroutlet',
weight=580,
hide_if_empty=True
)
@@ -1928,6 +1951,7 @@ class DeviceInterfacesView(DeviceComponentsView):
label=_('Interfaces'),
badge=lambda obj: obj.interfaces.count(),
permission='dcim.view_interface',
weight=520,
hide_if_empty=True
)
@@ -1948,6 +1972,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontports.count(),
permission='dcim.view_frontport',
weight=530,
hide_if_empty=True
)
@@ -1962,6 +1987,7 @@ class DeviceRearPortsView(DeviceComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearports.count(),
permission='dcim.view_rearport',
weight=540,
hide_if_empty=True
)
@@ -1976,6 +2002,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
label=_('Module Bays'),
badge=lambda obj: obj.modulebays.count(),
permission='dcim.view_modulebay',
weight=510,
hide_if_empty=True
)
@@ -1990,6 +2017,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
label=_('Device Bays'),
badge=lambda obj: obj.devicebays.count(),
permission='dcim.view_devicebay',
weight=500,
hide_if_empty=True
)
@@ -2004,6 +2032,7 @@ class DeviceInventoryView(DeviceComponentsView):
label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitems.count(),
permission='dcim.view_inventoryitem',
weight=590,
hide_if_empty=True
)
@@ -2014,7 +2043,8 @@ class DeviceConfigContextView(ObjectConfigContextView):
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext'
permission='extras.view_configcontext',
weight=2000
)
@@ -2072,6 +2102,7 @@ class NAPALMViewTab(ViewTab):
if not (
instance.status == 'active' and
instance.primary_ip and
instance.platform and
instance.platform.napalm_driver
):
return None
@@ -2086,6 +2117,7 @@ class DeviceStatusView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('Status'),
permission='dcim.napalm_read_device',
weight=3000
)
@@ -2097,6 +2129,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('LLDP Neighbors'),
permission='dcim.napalm_read_device',
weight=3100
)
def get_extra_context(self, request, instance):
@@ -2119,6 +2152,7 @@ class DeviceConfigView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('Config'),
permission='dcim.napalm_read_device',
weight=3200
)
@@ -2880,23 +2914,14 @@ class InventoryItemView(generic.ObjectView):
class InventoryItemEditView(generic.ObjectEditView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemForm
template_name = 'dcim/inventoryitem_edit.html'
class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
def alter_object(self, instance, request):
# Set component (if any)
component_type = request.GET.get('component_type')
component_id = request.GET.get('component_id')
if component_type and component_id:
content_type = get_object_or_404(ContentType, pk=component_type)
instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
return instance
template_name = 'dcim/inventoryitem_edit.html'
@register_model_view(InventoryItem, 'delete')
@@ -3578,7 +3603,9 @@ register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceVi
# VDC
class VirtualDeviceContextListView(generic.ObjectListView):
queryset = VirtualDeviceContext.objects.all()
queryset = VirtualDeviceContext.objects.annotate(
interface_count=count_related(Interface, 'vdcs'),
)
filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable
@@ -3591,6 +3618,7 @@ class VirtualDeviceContextView(generic.ObjectView):
def get_extra_context(self, request, instance):
interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
interfaces_table.configure(request)
interfaces_table.columns.hide('device')
return {
'interfaces_table': interfaces_table,

View File

@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
#
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
"values."
)
# Serialize object and multi-object values
for cf in self._get_custom_fields():
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
serializer_class = get_serializer_for_model(
model=cf.object_type.model_class(),
prefix=NESTED_SERIALIZER_PREFIX
)
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data}

View File

@@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer):
class Meta:
model = JobResult
fields = [
'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type',
'user', 'data', 'job_id',
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
'obj_type', 'user', 'data', 'job_id',
]
@@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer):
class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
#
@@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
class ScriptLogMessageSerializer(serializers.Serializer):

View File

@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404
from django_rq.queues import get_connection
from rest_framework import status
@@ -246,16 +245,14 @@ class ReportViewSet(ViewSet):
input_serializer = serializers.ReportInputSerializer(data=request.data)
if input_serializer.is_valid():
schedule_at = input_serializer.validated_data.get('schedule_at')
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
request.user,
name=report.full_name,
obj_type=ContentType.objects.get_for_model(Report),
user=request.user,
job_timeout=report.job_timeout,
schedule_at=schedule_at,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
)
report.result = job_result
@@ -329,21 +326,17 @@ class ScriptViewSet(ViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
data = input_serializer.data['data']
commit = input_serializer.data['commit']
schedule_at = input_serializer.validated_data.get('schedule_at')
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job(
run_script,
script.full_name,
script_content_type,
request.user,
data=data,
name=script.full_name,
obj_type=ContentType.objects.get_for_model(Script),
user=request.user,
data=input_serializer.data['data'],
request=copy_safe_request(request),
commit=commit,
commit=input_serializer.data['commit'],
job_timeout=script.job_timeout,
schedule_at=schedule_at,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
)
script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

View File

@@ -148,12 +148,12 @@ class JobResultStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed'
CHOICES = (
(STATUS_PENDING, 'Pending'),
(STATUS_SCHEDULED, 'Scheduled'),
(STATUS_RUNNING, 'Running'),
(STATUS_COMPLETED, 'Completed'),
(STATUS_ERRORED, 'Errored'),
(STATUS_FAILED, 'Failed'),
(STATUS_PENDING, 'Pending', 'cyan'),
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
(STATUS_RUNNING, 'Running', 'blue'),
(STATUS_COMPLETED, 'Completed', 'green'),
(STATUS_ERRORED, 'Errored', 'red'),
(STATUS_FAILED, 'Failed', 'red'),
)
TERMINAL_STATE_CHOICES = (

View File

@@ -17,10 +17,10 @@ __all__ = (
'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'JobResultFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JobResultFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
@@ -537,7 +537,7 @@ class JobResultFilterSet(BaseFilterSet):
class Meta:
model = JobResult
fields = ('id', 'status', 'user', 'obj_type', 'name')
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -32,7 +32,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
label=_('Model(s)')
)
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
@@ -116,6 +115,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all()
)
parameters = JSONField()
fieldsets = (
('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
@@ -125,9 +125,6 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = SavedFilter
exclude = ('user',)
widgets = {
'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
}
def __init__(self, *args, initial=None, **kwargs):

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
__all__ = (
'ReportForm',
@@ -15,3 +16,24 @@ class ReportForm(BootstrapMixin, forms.Form):
label=_("Schedule at"),
help_text=_("Schedule execution of report to a set time"),
)
interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
widget=SelectDurationWidget(),
help_text=_("Interval at which this report is re-run (in minutes)")
)
def clean_schedule_at(self):
scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < timezone.now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
__all__ = (
'ScriptForm',
@@ -21,19 +22,41 @@ class ScriptForm(BootstrapMixin, forms.Form):
label=_("Schedule at"),
help_text=_("Schedule execution of script to a set time"),
)
_interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
widget=SelectDurationWidget(),
help_text=_("Interval at which this script is re-run (in minutes)")
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
# Move _commit and _schedule_at to the end of the form
schedule_at = self.fields.pop('_schedule_at')
interval = self.fields.pop('_interval')
commit = self.fields.pop('_commit')
self.fields['_schedule_at'] = schedule_at
self.fields['_interval'] = interval
self.fields['_commit'] = commit
def clean__schedule_at(self):
scheduled_time = self.cleaned_data['_schedule_at']
if scheduled_time and scheduled_time < timezone.now():
raise forms.ValidationError({
'_schedule_at': _('Scheduled time must be in the future.')
})
return scheduled_time
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
A boolean indicating whether the form requires user input (ignore the built-in fields).
"""
return bool(len(self.fields) > 2)
return bool(len(self.fields) > 3)

View File

@@ -27,17 +27,28 @@ class Command(BaseCommand):
# Return only indexers for the specified models
else:
for label in model_names:
try:
app_label, model_name = label.lower().split('.')
except ValueError:
labels = label.lower().split('.')
# Label specifies an exact model
if len(labels) == 2:
app_label, model_name = labels
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
# Label specifies all the models of an app
elif len(labels) == 1:
app_label = labels[0] + '.'
for indexer_label, idx in registry['search'].items():
if indexer_label.startswith(app_label):
indexers[idx.model] = idx
else:
raise CommandError(
f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
f"Invalid model: {label}. Model names must be in the format <app_label> or <app_label>.<model_name>."
)
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
return indexers

View File

@@ -1,3 +1,4 @@
import django.core.validators
from django.db import migrations, models
@@ -13,6 +14,11 @@ class Migration(migrations.Migration):
name='scheduled',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='jobresult',
name='interval',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='jobresult',
name='started',

View File

@@ -2,6 +2,7 @@ import sys
import uuid
import django.db.models.deletion
import django.db.models.lookups
from django.core import management
from django.db import migrations, models
@@ -9,7 +10,16 @@ from django.db import migrations, models
def reindex(apps, schema_editor):
# Build the search index (except during tests)
if 'test' not in sys.argv:
management.call_command('reindex')
management.call_command(
'reindex',
'circuits',
'dcim',
'extras',
'ipam',
'tenancy',
'virtualization',
'wireless',
)
class Migration(migrations.Migration):
@@ -39,7 +49,7 @@ class Migration(migrations.Migration):
('object_id', models.PositiveBigIntegerField()),
('field', models.CharField(max_length=200)),
('type', models.CharField(max_length=30)),
('value', models.TextField(db_index=True)),
('value', models.TextField()),
('weight', models.PositiveSmallIntegerField(default=1000)),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
],

View File

@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import ValidationError
from django.core.validators import MinValueValidator, ValidationError
from django.db import models
from django.http import HttpResponse, QueryDict
from django.urls import reverse
@@ -21,6 +21,8 @@ from extras.choices import *
from extras.constants import *
from extras.conditions import ConditionSet
from extras.utils import FeatureQuery, image_upload
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
@@ -585,6 +587,14 @@ class JobResult(models.Model):
null=True,
blank=True
)
interval = models.PositiveIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(1),
),
help_text=_("Recurrence interval (in minutes)")
)
started = models.DateTimeField(
null=True,
blank=True
@@ -633,12 +643,20 @@ class JobResult(models.Model):
def get_absolute_url(self):
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
def get_status_color(self):
return JobResultStatusChoices.colors.get(self.status)
@property
def duration(self):
if not self.completed:
return None
duration = self.completed - self.created
start_time = self.started or self.created
if not start_time:
return None
duration = self.completed - start_time
minutes, seconds = divmod(duration.total_seconds(), 60)
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
@@ -662,32 +680,32 @@ class JobResult(models.Model):
self.completed = timezone.now()
@classmethod
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
"""
Create a JobResult instance and enqueue a job using the given callable
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
schedule_at: Schedule the job to be executed at the passed date and time
args: additional args passed to the callable
kwargs: additional kargs passed to the callable
Args:
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
schedule_at: Schedule the job to be executed at the passed date and time
interval: Recurrence interval (in minutes)
"""
job_result: JobResult = cls.objects.create(
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
job_result: JobResult = JobResult.objects.create(
name=name,
status=status,
obj_type=obj_type,
scheduled=schedule_at,
interval=interval,
user=user,
job_id=uuid.uuid4()
)
queue = django_rq.get_queue("default")
if schedule_at:
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
job_result.scheduled = schedule_at
job_result.save()
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
else:
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)

View File

@@ -36,9 +36,7 @@ class CachedValue(models.Model):
type = models.CharField(
max_length=30
)
value = models.TextField(
db_index=True
)
value = models.TextField()
weight = models.PositiveSmallIntegerField(
default=1000
)

View File

@@ -19,6 +19,10 @@ class PluginMenu:
if icon_class is not None:
self.icon_class = icon_class
@property
def name(self):
return self.label.replace(' ', '_')
class PluginMenuItem:
"""

View File

@@ -1,8 +1,8 @@
import importlib
import inspect
import logging
import pkgutil
import traceback
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
@@ -11,7 +11,6 @@ from django_rq import job
from .choices import JobResultStatusChoices, LogLevelChoices
from .models import JobResult
logger = logging.getLogger(__name__)
@@ -85,10 +84,24 @@ def run_report(job_result, *args, **kwargs):
try:
job_result.start()
report.run(job_result)
except Exception as e:
except Exception:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
logging.error(f"Error during execution of report {job_result.name}")
finally:
# Schedule the next job if an interval has been set
start_time = job_result.scheduled or job_result.started
if start_time and job_result.interval:
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_report,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
job_timeout=report.job_timeout,
schedule_at=new_scheduled_time,
interval=job_result.interval
)
class Report(object):

View File

@@ -4,8 +4,9 @@ import logging
import os
import pkgutil
import sys
import traceback
import threading
import traceback
from datetime import timedelta
import yaml
from django import forms
@@ -16,6 +17,7 @@ from django.utils.functional import classproperty
from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -491,6 +493,22 @@ def run_script(data, request, commit=True, *args, **kwargs):
else:
_run_script()
# Schedule the next job if an interval has been set
if job_result.interval:
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_script,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
schedule_at=new_scheduled_time,
interval=job_result.interval,
job_timeout=script.job_timeout,
data=data,
request=request,
commit=commit
)
def get_scripts(use_names=False):
"""

View File

@@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
#
# Change logging/webhooks
#
@@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
@@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs):
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id

View File

@@ -1,5 +1,6 @@
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import *
from netbox.tables import NetBoxTable, columns
@@ -8,9 +9,9 @@ from .template_code import *
__all__ = (
'ConfigContextTable',
'CustomFieldTable',
'JobResultTable',
'CustomLinkTable',
'ExportTemplateTable',
'JobResultTable',
'JournalEntryTable',
'ObjectChangeTable',
'SavedFilterTable',
@@ -41,7 +42,15 @@ class JobResultTable(NetBoxTable):
name = tables.Column(
linkify=True
)
obj_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
status = columns.ChoiceFieldColumn()
created = columns.DateTimeColumn()
scheduled = columns.DateTimeColumn()
interval = columns.DurationColumn()
started = columns.DateTimeColumn()
completed = columns.DateTimeColumn()
actions = columns.ActionsColumn(
actions=('delete',)
)
@@ -49,10 +58,12 @@ class JobResultTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = JobResult
fields = (
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', 'job_id',
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
'user', 'job_id',
)
default_columns = (
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user',
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
'user',
)
@@ -215,7 +226,8 @@ class ObjectChangeTable(NetBoxTable):
object_repr = tables.TemplateColumn(
accessor=tables.A('changed_object'),
template_code=OBJECTCHANGE_OBJECT,
verbose_name='Object'
verbose_name='Object',
orderable=False
)
request_id = tables.TemplateColumn(
template_code=OBJECTCHANGE_REQUEST_ID,

View File

@@ -26,7 +26,7 @@ items = (
)
menu = PluginMenu(
label=_('Dummy'),
label=_('Dummy Plugin'),
groups=(('Group 1', items),),
)
menu_items = items

View File

@@ -611,73 +611,76 @@ class ScriptTest(APITestCase):
class CreatedUpdatedFilterTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1')
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rack1 = Rack.objects.create(
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42,
)
self.rack2 = Rack.objects.create(
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42,
@classmethod
def setUpTestData(cls):
site1 = Site.objects.create(name='Site 1', slug='site-1')
location1 = Location.objects.create(site=site1, name='Location 1', slug='location-1')
rackrole1 = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
racks = (
Rack(site=site1, location=location1, role=rackrole1, name='Rack 1', u_height=42),
Rack(site=site1, location=location1, role=rackrole1, name='Rack 2', u_height=42)
)
Rack.objects.bulk_create(racks)
# change the created and last_updated of one
Rack.objects.filter(pk=self.rack2.pk).update(
# Change the created and last_updated of the second rack
Rack.objects.filter(pk=racks[1].pk).update(
last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
created=make_aware(datetime.datetime(2001, 2, 3))
)
def test_get_rack_created(self):
rack2 = Rack.objects.get(name='Rack 2')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
def test_get_rack_created_gte(self):
rack1 = Rack.objects.get(name='Rack 1')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
self.assertEqual(response.data['results'][0]['id'], rack1.pk)
def test_get_rack_created_lte(self):
rack2 = Rack.objects.get(name='Rack 2')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
def test_get_rack_last_updated(self):
rack2 = Rack.objects.get(name='Rack 2')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
def test_get_rack_last_updated_gte(self):
rack1 = Rack.objects.get(name='Rack 1')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
self.assertEqual(response.data['results'][0]['id'], rack1.pk)
def test_get_rack_last_updated_lte(self):
rack2 = Rack.objects.get(name='Rack 2')
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
class ContentTypeTest(APITestCase):

View File

@@ -373,7 +373,8 @@ class CustomFieldTest(TestCase):
class CustomFieldManagerTest(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site)
custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
custom_field.save()
@@ -853,6 +854,69 @@ class CustomFieldAPITest(APITestCase):
list(original_cfvs['multiobject_field'])
)
def test_specify_related_object_by_attr(self):
site1 = Site.objects.get(name='Site 1')
vlans = VLAN.objects.all()[:3]
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.change_site')
# Set related objects by PK
data = {
'custom_fields': {
'object_field': vlans[0].pk,
'multiobject_field': [vlans[1].pk, vlans[2].pk],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
# Set related objects by name
data = {
'custom_fields': {
'object_field': {
'name': vlans[0].name,
},
'multiobject_field': [
{
'name': vlans[1].name
},
{
'name': vlans[2].name
},
],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
# Clear related objects
data = {
'custom_fields': {
'object_field': None,
'multiobject_field': [],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIsNone(response.data['custom_fields']['object_field'])
self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
def test_minimum_maximum_values_validation(self):
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

View File

@@ -21,32 +21,32 @@ class ConfigContextTest(TestCase):
It also ensures the various config context querysets are consistent.
"""
def setUp(self):
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
self.region = Region.objects.create(name="Region")
self.sitegroup = SiteGroup.objects.create(name="Site Group")
self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
self.platform = Platform.objects.create(name="Platform")
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
self.tag = Tag.objects.create(name="Tag", slug="tag")
self.tag2 = Tag.objects.create(name="Tag2", slug="tag2")
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
region = Region.objects.create(name='Region')
sitegroup = SiteGroup.objects.create(name='Site Group')
site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup)
location = Location.objects.create(name='Location 1', slug='location-1', site=site)
platform = Platform.objects.create(name='Platform')
tenantgroup = TenantGroup.objects.create(name='Tenant Group')
tenant = Tenant.objects.create(name='Tenant', group=tenantgroup)
tag1 = Tag.objects.create(name='Tag', slug='tag')
tag2 = Tag.objects.create(name='Tag2', slug='tag2')
self.device = Device.objects.create(
Device.objects.create(
name='Device 1',
device_type=self.devicetype,
device_role=self.devicerole,
site=self.site,
location=self.location
device_type=devicetype,
device_role=devicerole,
site=site,
location=location
)
def test_higher_weight_wins(self):
device = Device.objects.first()
context1 = ConfigContext(
name="context 1",
weight=101,
@@ -72,10 +72,10 @@ class ConfigContextTest(TestCase):
"b": 456,
"c": 777
}
self.assertEqual(self.device.get_config_context(), expected_data)
self.assertEqual(device.get_config_context(), expected_data)
def test_name_ordering_after_weight(self):
device = Device.objects.first()
context1 = ConfigContext(
name="context 1",
weight=100,
@@ -101,13 +101,14 @@ class ConfigContextTest(TestCase):
"b": 456,
"c": 789
}
self.assertEqual(self.device.get_config_context(), expected_data)
self.assertEqual(device.get_config_context(), expected_data)
def test_annotation_same_as_get_for_object(self):
"""
This test incorperates features from all of the above tests cases to ensure
This test incorporates features from all of the above tests cases to ensure
the annotate_config_context_data() and get_for_object() queryset methods are the same.
"""
device = Device.objects.first()
context1 = ConfigContext(
name="context 1",
weight=101,
@@ -142,10 +143,19 @@ class ConfigContextTest(TestCase):
)
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data()
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_annotation_same_as_get_for_object_device_relations(self):
region = Region.objects.first()
sitegroup = SiteGroup.objects.first()
site = Site.objects.first()
location = Location.objects.first()
platform = Platform.objects.first()
tenantgroup = TenantGroup.objects.first()
tenant = Tenant.objects.first()
tag = Tag.objects.first()
region_context = ConfigContext.objects.create(
name="region",
weight=100,
@@ -153,7 +163,8 @@ class ConfigContextTest(TestCase):
"region": 1
}
)
region_context.regions.add(self.region)
region_context.regions.add(region)
sitegroup_context = ConfigContext.objects.create(
name="sitegroup",
weight=100,
@@ -161,7 +172,8 @@ class ConfigContextTest(TestCase):
"sitegroup": 1
}
)
sitegroup_context.site_groups.add(self.sitegroup)
sitegroup_context.site_groups.add(sitegroup)
site_context = ConfigContext.objects.create(
name="site",
weight=100,
@@ -169,7 +181,8 @@ class ConfigContextTest(TestCase):
"site": 1
}
)
site_context.sites.add(self.site)
site_context.sites.add(site)
location_context = ConfigContext.objects.create(
name="location",
weight=100,
@@ -177,7 +190,8 @@ class ConfigContextTest(TestCase):
"location": 1
}
)
location_context.locations.add(self.location)
location_context.locations.add(location)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,
@@ -185,7 +199,8 @@ class ConfigContextTest(TestCase):
"platform": 1
}
)
platform_context.platforms.add(self.platform)
platform_context.platforms.add(platform)
tenant_group_context = ConfigContext.objects.create(
name="tenant group",
weight=100,
@@ -193,7 +208,8 @@ class ConfigContextTest(TestCase):
"tenant_group": 1
}
)
tenant_group_context.tenant_groups.add(self.tenantgroup)
tenant_group_context.tenant_groups.add(tenantgroup)
tenant_context = ConfigContext.objects.create(
name="tenant",
weight=100,
@@ -201,7 +217,8 @@ class ConfigContextTest(TestCase):
"tenant": 1
}
)
tenant_context.tenants.add(self.tenant)
tenant_context.tenants.add(tenant)
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
@@ -209,23 +226,30 @@ class ConfigContextTest(TestCase):
"tag": 1
}
)
tag_context.tags.add(self.tag)
tag_context.tags.add(tag)
device = Device.objects.create(
name="Device 2",
site=self.site,
location=self.location,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
site=site,
location=location,
tenant=tenant,
platform=platform,
device_role=DeviceRole.objects.first(),
device_type=DeviceType.objects.first()
)
device.tags.add(self.tag)
device.tags.add(tag)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
region = Region.objects.first()
sitegroup = SiteGroup.objects.first()
site = Site.objects.first()
platform = Platform.objects.first()
tenantgroup = TenantGroup.objects.first()
tenant = Tenant.objects.first()
tag = Tag.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
@@ -235,49 +259,49 @@ class ConfigContextTest(TestCase):
weight=100,
data={"region": 1}
)
region_context.regions.add(self.region)
region_context.regions.add(region)
sitegroup_context = ConfigContext.objects.create(
name="sitegroup",
weight=100,
data={"sitegroup": 1}
)
sitegroup_context.site_groups.add(self.sitegroup)
sitegroup_context.site_groups.add(sitegroup)
site_context = ConfigContext.objects.create(
name="site",
weight=100,
data={"site": 1}
)
site_context.sites.add(self.site)
site_context.sites.add(site)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,
data={"platform": 1}
)
platform_context.platforms.add(self.platform)
platform_context.platforms.add(platform)
tenant_group_context = ConfigContext.objects.create(
name="tenant group",
weight=100,
data={"tenant_group": 1}
)
tenant_group_context.tenant_groups.add(self.tenantgroup)
tenant_group_context.tenant_groups.add(tenantgroup)
tenant_context = ConfigContext.objects.create(
name="tenant",
weight=100,
data={"tenant": 1}
)
tenant_context.tenants.add(self.tenant)
tenant_context.tenants.add(tenant)
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
data={"tag": 1}
)
tag_context.tags.add(self.tag)
tag_context.tags.add(tag)
cluster_type_context = ConfigContext.objects.create(
name="cluster type",
@@ -303,11 +327,11 @@ class ConfigContextTest(TestCase):
virtual_machine = VirtualMachine.objects.create(
name="VM 1",
cluster=cluster,
tenant=self.tenant,
platform=self.platform,
role=self.devicerole
tenant=tenant,
platform=platform,
role=DeviceRole.objects.first()
)
virtual_machine.tags.add(self.tag)
virtual_machine.tags.add(tag)
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
@@ -315,12 +339,17 @@ class ConfigContextTest(TestCase):
def test_multiple_tags_return_distinct_objects(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
This is combatted by by appending distinct() to the config context querysets. This test creates a config
This is combated by appending distinct() to the config context querysets. This test creates a config
context assigned to two tags and ensures objects related by those same two tags result in only a single
config context record being returned.
See https://github.com/netbox-community/netbox/issues/5314
"""
site = Site.objects.first()
platform = Platform.objects.first()
tenant = Tenant.objects.first()
tags = Tag.objects.all()
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
@@ -328,19 +357,17 @@ class ConfigContextTest(TestCase):
"tag": 1
}
)
tag_context.tags.add(self.tag)
tag_context.tags.add(self.tag2)
tag_context.tags.set(tags)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
site=site,
tenant=tenant,
platform=platform,
device_role=DeviceRole.objects.first(),
device_type=DeviceType.objects.first()
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
device.tags.set(tags)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
@@ -357,6 +384,11 @@ class ConfigContextTest(TestCase):
See https://github.com/netbox-community/netbox/issues/5387
"""
site = Site.objects.first()
platform = Platform.objects.first()
tenant = Tenant.objects.first()
tag1, tag2 = list(Tag.objects.all())
tag_context_1 = ConfigContext.objects.create(
name="tag-1",
weight=100,
@@ -364,7 +396,8 @@ class ConfigContextTest(TestCase):
"tag": 1
}
)
tag_context_1.tags.add(self.tag)
tag_context_1.tags.add(tag1)
tag_context_2 = ConfigContext.objects.create(
name="tag-2",
weight=100,
@@ -372,18 +405,17 @@ class ConfigContextTest(TestCase):
"tag": 1
}
)
tag_context_2.tags.add(self.tag2)
tag_context_2.tags.add(tag2)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
site=site,
tenant=tenant,
platform=platform,
device_role=DeviceRole.objects.first(),
device_type=DeviceType.objects.first()
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
device.tags.set([tag1, tag2])
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)

View File

@@ -76,7 +76,7 @@ class PluginTest(TestCase):
"""
menu = registry['plugins']['menus'][0]
self.assertIsInstance(menu, PluginMenu)
self.assertEqual(menu.label, 'Dummy')
self.assertEqual(menu.label, 'Dummy Plugin')
def test_menu_items(self):
"""

View File

@@ -23,6 +23,7 @@ class WebhookTest(APITestCase):
def setUp(self):
super().setUp()
# Ensure the queue has been cleared for each test
self.queue = django_rq.get_queue('default')
self.queue.empty()

View File

@@ -676,7 +676,6 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
form = ReportForm(request.POST)
if form.is_valid():
schedule_at = form.cleaned_data.get("schedule_at")
# Allow execution only if RQ worker process is running
if not Worker.count(get_connection('default')):
@@ -686,14 +685,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
})
# Run the Report. A new JobResult is created.
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
request.user,
job_timeout=report.job_timeout,
schedule_at=schedule_at,
name=report.full_name,
obj_type=ContentType.objects.get_for_model(Report),
user=request.user,
schedule_at=form.cleaned_data.get('schedule_at'),
interval=form.cleaned_data.get('interval'),
job_timeout=report.job_timeout
)
return redirect('extras:report_result', job_result_pk=job_result.pk)
@@ -787,9 +786,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
script.result = JobResult.objects.filter(
obj_type=script_content_type,
obj_type=ContentType.objects.get_for_model(Script),
name=script.full_name,
).exclude(
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
@@ -815,21 +813,17 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
messages.error(request, "Unable to run script: RQ worker process not running.")
elif form.is_valid():
commit = form.cleaned_data.pop('_commit')
schedule_at = form.cleaned_data.pop("_schedule_at")
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job(
run_script,
script.full_name,
script_content_type,
request.user,
name=script.full_name,
obj_type=ContentType.objects.get_for_model(Script),
user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'),
data=form.cleaned_data,
request=copy_safe_request(request),
commit=commit,
job_timeout=script.job_timeout,
schedule_at=schedule_at,
commit=form.cleaned_data.pop('_commit')
)
return redirect('extras:script_result', job_result_pk=job_result.pk)

View File

@@ -5,6 +5,8 @@ from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django_rq import get_queue
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
from utilities.api import get_serializer_for_model
from utilities.utils import serialize_object
@@ -78,7 +80,8 @@ def flush_webhooks(queue):
"""
Flush a list of object representation to RQ for webhook processing.
"""
rq_queue = get_queue('default')
rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
rq_queue = get_queue(rq_queue_name)
webhooks_cache = {
'type_create': {},
'type_update': {},

View File

@@ -962,7 +962,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = L2VPN
fields = ['id', 'identifier', 'name', 'type', 'description']
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -249,7 +249,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
null_option='Global'
)
status = MultipleChoiceField(
choices=PrefixStatusChoices,
choices=IPRangeStatusChoices,
required=False
)
role_id = DynamicModelMultipleChoiceField(

View File

@@ -436,7 +436,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface:
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
if cluster := nat_inside_parent.virtual_machine.cluster:
initial['nat_cluster'] = cluster.pk
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial

View File

@@ -9,8 +9,6 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from dcim.fields import ASNField
from dcim.models import Device
from netbox.models import OrganizationalModel, PrimaryModel
from ipam.choices import *
from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField
@@ -18,8 +16,7 @@ from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from virtualization.models import VirtualMachine
from netbox.models import OrganizationalModel, PrimaryModel
__all__ = (
'Aggregate',
@@ -101,6 +98,10 @@ class ASN(PrimaryModel):
null=True
)
prerequisite_models = (
'ipam.RIR',
)
class Meta:
ordering = ['asn']
verbose_name = 'ASN'
@@ -109,10 +110,6 @@ class ASN(PrimaryModel):
def __str__(self):
return f'AS{self.asn_with_asdot}'
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk])
@@ -163,6 +160,9 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
clone_fields = (
'rir', 'tenant', 'date_added', 'description',
)
prerequisite_models = (
'ipam.RIR',
)
class Meta:
ordering = ('prefix', 'pk') # prefix may be non-unique
@@ -170,10 +170,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
def __str__(self):
return str(self.prefix)
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self):
return reverse('ipam:aggregate', args=[self.pk])
@@ -866,18 +862,6 @@ class IPAddress(PrimaryModel):
)
})
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk:
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if parent and getattr(self.assigned_object, attr, None) != parent:
# Check for a NAT relationship
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
raise ValidationError({
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
f"not assigned to it!"
})
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({

View File

@@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@@ -95,6 +94,9 @@ class L2VPNTermination(NetBoxModel):
)
clone_fields = ('l2vpn',)
prerequisite_models = (
'ipam.L2VPN',
)
class Meta:
ordering = ('l2vpn',)
@@ -111,10 +113,6 @@ class L2VPNTermination(NetBoxModel):
return f'{self.assigned_object} <> {self.l2vpn}'
return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('ipam.L2VPN'), ]
def get_absolute_url(self):
return reverse('ipam:l2vpntermination', args=[self.pk])

View File

@@ -33,6 +33,9 @@ class FHRPGroupTable(NetBoxTable):
url_name='ipam:fhrpgroup_list'
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
class Meta(NetBoxTable.Meta):
model = FHRPGroup
fields = (

View File

@@ -31,16 +31,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='ipam:prefix_list'
url_name='ipam:l2vpn_list'
)
class Meta(NetBoxTable.Meta):
model = L2VPN
fields = (
'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
'description', 'comments', 'tags', 'created', 'last_updated', 'actions',
'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
class L2VPNTerminationTable(NetBoxTable):

View File

@@ -1505,6 +1505,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['L2VPN 1', 'L2VPN 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['l2vpn-1', 'l2vpn-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_identifier(self):
params = {'identifier': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -9,12 +9,17 @@ import netaddr
class OrderingTestBase(TestCase):
vrfs = None
def setUp(self):
@classmethod
def setUpTestData(cls):
"""
Setup the VRFs for the class as a whole
"""
self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C"))
VRF.objects.bulk_create(self.vrfs)
vrfs = (
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
)
VRF.objects.bulk_create(vrfs)
def _compare(self, queryset, objectset):
"""
@@ -37,10 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
"""
This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Prefixes
vrf1, vrf2, vrf3 = list(VRF.objects.all())
prefixes = (
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')),
@@ -50,37 +52,37 @@ class PrefixOrderingTestCase(OrderingTestBase):
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.5.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/12')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/12')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.4.0/24')),
)
Prefix.objects.bulk_create(prefixes)
@@ -104,20 +106,17 @@ class PrefixOrderingTestCase(OrderingTestBase):
VRF A:10.1.1.0/24
None: 192.168.0.0/16
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Prefixes
vrf1, vrf2, vrf3 = list(VRF.objects.all())
prefixes = [
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('10.1.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/25')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/25')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')),
]
Prefix.objects.bulk_create(prefixes)
@@ -131,37 +130,34 @@ class IPAddressOrderingTestCase(OrderingTestBase):
"""
This function tests ordering with the inclusion of vrfs
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Addresses
vrf1, vrf2, vrf3 = list(VRF.objects.all())
addresses = (
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.1.1/24')),

View File

@@ -314,7 +314,8 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('Prefixes'),
badge=lambda x: x.get_child_prefixes().count(),
permission='ipam.view_prefix'
permission='ipam.view_prefix',
weight=500
)
def get_children(self, request, parent):
@@ -502,7 +503,8 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('Child Prefixes'),
badge=lambda x: x.get_child_prefixes().count(),
permission='ipam.view_prefix'
permission='ipam.view_prefix',
weight=500
)
def get_children(self, request, parent):
@@ -536,7 +538,8 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('Child Ranges'),
badge=lambda x: x.get_child_ranges().count(),
permission='ipam.view_iprange'
permission='ipam.view_iprange',
weight=600
)
def get_children(self, request, parent):
@@ -561,7 +564,8 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('IP Addresses'),
badge=lambda x: x.get_child_ips().count(),
permission='ipam.view_ipaddress'
permission='ipam.view_ipaddress',
weight=700
)
def get_children(self, request, parent):
@@ -635,7 +639,8 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('IP Addresses'),
badge=lambda x: x.get_child_ips().count(),
permission='ipam.view_ipaddress'
permission='ipam.view_ipaddress',
weight=500
)
def get_children(self, request, parent):
@@ -1075,7 +1080,8 @@ class VLANInterfacesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
permission='dcim.view_interface'
permission='dcim.view_interface',
weight=500
)
def get_children(self, request, parent):
@@ -1092,7 +1098,8 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
permission='virtualization.view_vminterface'
permission='virtualization.view_vminterface',
weight=510
)
def get_children(self, request, parent):

View File

@@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
# Overrides ListModelMixin to allow processing ExportTemplates.
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()

View File

@@ -382,5 +382,4 @@ def user_default_groups_handler(backend, user, response, *args, **kwargs):
if group_list:
user.groups.add(*group_list)
else:
user.groups.clear()
logger.debug(f"Stripping user {user} from Groups")
logger.info(f"No valid group assignments for {user} - REMOTE_AUTH_DEFAULT_GROUPS may be incorrectly set?")

View File

@@ -31,6 +31,7 @@ REDIS = {
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 0,
'SSL': False,
@@ -44,6 +45,7 @@ REDIS = {
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,
@@ -106,6 +108,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# on a production system.
DEBUG = False
# Set the default preferred language/locale
DEFAULT_LANGUAGE = 'en-us'
# Email settings
EMAIL = {
'SERVER': 'localhost',
@@ -152,6 +157,9 @@ LOGIN_REQUIRED = False
# re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None
# The view name or URL to which users are redirected after logging out.
LOGOUT_REDIRECT_URL = 'home'
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
# the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media'
@@ -216,6 +224,9 @@ SESSION_COOKIE_NAME = 'sessionid'
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
SESSION_FILE_PATH = None
# Localization
ENABLE_LOCALIZATION = False
# Time zone (default: UTC)
TIME_ZONE = 'UTC'

View File

@@ -22,6 +22,7 @@ REDIS = {
'tasks': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 0,
'SSL': False,
@@ -29,6 +30,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@@ -1,2 +1,7 @@
# Prefix for nested serializers
NESTED_SERIALIZER_PREFIX = 'Nested'
# RQ queue names
RQ_QUEUE_DEFAULT = 'default'
RQ_QUEUE_HIGH = 'high'
RQ_QUEUE_LOW = 'low'

View File

@@ -1,3 +1,5 @@
import re
from django import forms
from django.utils.translation import gettext as _
@@ -12,6 +14,7 @@ LOOKUP_CHOICES = (
(LookupTypes.EXACT, _('Exact match')),
(LookupTypes.STARTSWITH, _('Starts with')),
(LookupTypes.ENDSWITH, _('Ends with')),
(LookupTypes.REGEX, _('Regex')),
)
@@ -43,3 +46,14 @@ class SearchForm(BootstrapMixin, forms.Form):
super().__init__(*args, **kwargs)
self.fields['obj_types'].choices = search_backend.get_object_types()
def clean(self):
# Validate regular expressions
if self.cleaned_data['lookup'] == LookupTypes.REGEX:
try:
re.compile(self.cleaned_data['q'])
except re.error as e:
raise forms.ValidationError({
'q': f'Invalid regular expression: {e}'
})

View File

@@ -1,6 +1,5 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext as _
@@ -48,6 +47,9 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
# Save custom field data on instance
for cf_name, customfield in self.custom_fields.items():
if cf_name not in self.fields:
# Custom fields may be absent when performing bulk updates via import
continue
key = cf_name[3:] # Strip "cf_" from field name
value = self.cleaned_data.get(cf_name)

View File

@@ -54,8 +54,7 @@ class ObjectListField(DjangoListField):
@staticmethod
def list_resolver(django_object_type, resolver, default_manager, root, info, **args):
# Get the QuerySet from the object type
queryset = django_object_type.get_queryset(default_manager, info)
queryset = super(ObjectListField, ObjectListField).list_resolver(django_object_type, resolver, default_manager, root, info, **args)
# Instantiate and apply the FilterSet, if defined
filterset_class = django_object_type._meta.filterset_class

View File

@@ -3,9 +3,9 @@ from django.core.validators import ValidationError
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
from netbox.models.features import *
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from netbox.models.features import *
__all__ = (
'ChangeLoggedModel',
@@ -33,14 +33,6 @@ class NetBoxFeatureSet(
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
@classmethod
def get_prerequisite_models(cls):
"""
Return a list of model types that are required to create this model or empty list if none. This is used for
showing prerequisite warnings in the UI on the list and detail views.
"""
return []
#
# Base model classes
@@ -83,7 +75,7 @@ class PrimaryModel(NetBoxModel):
abstract = True
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel):
"""
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using MPTT. Within each parent, each child instance must have a unique name.

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