Compare commits

...

74 Commits

Author SHA1 Message Date
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
80f5eeacdd Fix issues loading demo data 2022-11-29 09:18:03 -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
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
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
e40e2550a6 PRVB 2022-11-16 11:34:45 -05:00
Jeremy Stretch
bfda5d9011 Merge pull request #10937 from netbox-community/develop
Release v3.3.8
2022-11-16 11:32:44 -05:00
jeremystretch
62a80c46a8 Release v3.3.8 2022-11-16 10:45:29 -05:00
jeremystretch
ceec1055e0 Changelog for #10356, #10904, #10920 2022-11-16 10:40:18 -05:00
jeremystretch
540bba4544 Closes #10920: Include request cookies when queuing a custom script 2022-11-16 10:37:06 -05:00
jeremystretch
44c248e6c2 Closes #10934: Update release package URL 2022-11-16 10:36:30 -05:00
Patrick Hurrelmann
3a62fd49e6 Fixes: #10356 backplane connections (#10554)
* Fixes: #10356 Add interface type and cable for backplane connections

* Allow Backplone for front and readports , too.

* Correct tyo in port definition

* pep8 fix (blank lines)

* Remove port type and changed name/description of backplane cable

* Omit backplane cable type

Co-authored-by: Patrick Hurrelmann <patrick.hurrelmann@nfon.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 10:26:46 -05:00
Derick Vigne
a2007a4728 Closes #10904: Added Colors to SVG for Front and Rear Ports (#10905)
* Added Colors to SVG for Front and Reaer Ports

Fix for feature request 10904 thanks to @TheZackCodec

* Simplify termination color resolution

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 09:57:49 -05:00
jeremystretch
316c3808f7 Changelog for #9439, #10902, #10914, #10915, #10919 2022-11-16 09:43:11 -05:00
Arthur Hanson
928d880f0e 10902 add location to power feed form (#10913)
* 10902 add location to power feed form

* Bind location field to selected site

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 09:27:49 -05:00
Arthur
c6930e3ea8 10919 add location to cable termination panels 2022-11-16 09:22:39 -05:00
Arthur
564884a774 10903 add module type on manufacturer page 2022-11-14 14:17:06 -05:00
Arthur
7401fd7050 10909 add l2vpn to tenant stats 2022-11-14 14:16:18 -05:00
jeremystretch
4a95cfd1c4 Permanently connect change logging & webhook receivers 2022-11-14 09:31:25 -05:00
jeremystretch
cd8943144b Use context vars instead of thread-local storage for change logging 2022-11-14 09:31:25 -05:00
jeremystretch
8400509358 Fixes #10891: Populate tag selection list for service filter form 2022-11-14 09:28:03 -05:00
jeremystretch
d971131198 Fixes #10897: Fix form widget styling on FHRP group form 2022-11-14 09:24:12 -05:00
jeremystretch
5729a06348 Fixes #10910: Fix cable creation links on power port view 2022-11-14 09:20:02 -05:00
jeremystretch
d59d23e308 Fixes #10881: Fix dark mode coloring for data on device status page 2022-11-10 16:47:17 -05:00
jeremystretch
3d1501e8fd Changelog for #10837, #10874 2022-11-10 16:33:34 -05:00
Brian Candler
c854c29016 Fix broken cookie paths when BASE_PATH is set (introduced in #10706) (#10856)
Fixes #10837
2022-11-10 16:23:05 -05:00
Arthur Hanson
33d8f8e5e7 10874 remove link to contact roles (#10879) 2022-11-10 16:19:43 -05:00
jeremystretch
93e241e8f3 Changelog for #10709, #10829 2022-11-04 16:56:52 -04:00
Arthur
43da786016 10829 fix top edit selected button 2022-11-04 16:54:24 -04:00
Arthur
271d524687 10709 add AzureAD Tenant Oauth2 2022-11-04 09:12:22 -04:00
Arthur Hanson
4ebcdd2b8f 8072 move js code from template to static file (#10824) 2022-11-03 09:29:45 -04:00
jeremystretch
2af8891f70 PRVB 2022-11-01 17:11:55 -04:00
jeremystretch
4e39021b6f Merge branch 'master' into develop 2022-11-01 17:10:18 -04:00
jeremystretch
2cd5fce62d Release v3.3.7 2022-11-01 17:09:55 -04:00
jeremystretch
ade307bc03 Fixes #10809: Permit nullifying site time_zone via REST API 2022-11-01 17:09:55 -04:00
jeremystretch
c8be4ef8e2 Fixes #10791: Permit nullifying VLAN group scope_type via REST API 2022-11-01 17:09:55 -04:00
jeremystretch
816214361d Fixes #10803: Fix exception when ordering contacts by number of assignments 2022-11-01 17:09:55 -04:00
jeremystretch
d1970ca85b Changelog for #10282, #10770 2022-11-01 17:09:55 -04:00
Arthur
8001694a4c 10282 fix race condition in API IP creation 2022-11-01 17:09:55 -04:00
Arthur
10e258739f 10770 fix social auth 2022-11-01 17:09:55 -04:00
jeremystretch
f3fdf03661 Changelog for #10666 (missed in v3.3.6) 2022-11-01 17:09:55 -04:00
jeremystretch
44814f759c PRVB 2022-11-01 17:09:55 -04:00
jeremystretch
4f5caa5ed2 Release v3.3.7 2022-11-01 16:48:40 -04:00
jeremystretch
aa7f04bf1b Fixes #10809: Permit nullifying site time_zone via REST API 2022-11-01 16:45:32 -04:00
jeremystretch
aaf1ea52b7 Fixes #10791: Permit nullifying VLAN group scope_type via REST API 2022-11-01 15:38:10 -04:00
jeremystretch
7990cfb078 Fixes #10803: Fix exception when ordering contacts by number of assignments 2022-11-01 15:27:35 -04:00
jeremystretch
a25ee66150 Changelog for #10282, #10770 2022-10-31 15:15:45 -04:00
Arthur
867af61875 10282 fix race condition in API IP creation 2022-10-31 15:04:43 -04:00
Arthur
8f4fa065f9 10770 fix social auth 2022-10-31 15:02:57 -04:00
jeremystretch
edb5220228 Changelog for #10666 (missed in v3.3.6) 2022-10-26 15:11:44 -04:00
jeremystretch
18332bdbf1 PRVB 2022-10-26 10:23:50 -04:00
56 changed files with 558 additions and 267 deletions

View File

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

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

@@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight
sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
```

View File

@@ -1,5 +1,70 @@
# NetBox v3.3
## 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
---
## v3.3.8 (2022-11-16)
### Enhancements
* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
### Bug Fixes
* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
---
## v3.3.7 (2022-11-01)
### Bug Fixes
* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions
* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users
* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API
* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments
* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API
---
## v3.3.6 (2022-10-26)
### Enhancements
@@ -19,6 +84,7 @@
* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests
* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view

View File

@@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False)
time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField(
queryset=ASN.objects.all(),
serializer=NestedASNSerializer,

View File

@@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr'
TYPE_40GE_KR4 = '40gbase-kr4'
TYPE_50GE_KR = '50gbase-kr'
TYPE_100GE_KP4 = '100gbase-kp4'
TYPE_100GE_KR2 = '100gbase-kr2'
TYPE_100GE_KR4 = '100gbase-kr4'
# Wireless
TYPE_80211A = 'ieee802.11a'
TYPE_80211G = 'ieee802.11g'
@@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
)
),
(
'Ethernet (backplane)',
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
(TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
(TYPE_50GE_KR, '50GBASE-KR (50GE)'),
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
)
),
(
'Wireless',
(

View File

@@ -1218,7 +1218,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(
@@ -1229,7 +1229,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

@@ -877,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm):
'site_id': '$site'
}
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site'
},
initial_params={
'racks': '$rack'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'location_id': '$location',
'site_id': '$site'
}
)
@@ -888,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm):
fieldsets = (
('Power Panel', ('region', 'site', 'power_panel')),
('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
)
class Meta:
model = PowerFeed
fields = [
'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
]
widgets = {

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")
@@ -570,6 +581,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

View File

@@ -189,7 +189,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',

View File

@@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
max_length=200
)
clone_fields = ('rack', 'user', 'tenant')
class Meta:
ordering = ['created', '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

@@ -166,7 +166,7 @@ class CableTraceSVG:
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color

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

@@ -589,17 +589,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)

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 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

@@ -1,10 +1,6 @@
from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from netbox.context import current_request, webhooks_queue
from .webhooks import flush_webhooks
@@ -16,27 +12,14 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
set_request(request)
thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
current_request.set(request)
webhooks_queue.set([])
yield
# Disconnect change logging signals. This is necessary to avoid recording any errant
# changes during test cleanup.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
flush_webhooks(thread_locals.webhook_queue)
del thread_locals.webhook_queue
flush_webhooks(webhooks_queue.get())
# Clear the request from thread-local storage
set_request(None)
# Clear context vars
current_request.set(None)
webhooks_queue.set([])

View File

@@ -7,9 +7,8 @@ from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.validators import CustomValidator
from netbox import thread_locals
from netbox.config import get_config
from netbox.request_context import get_request
from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
@@ -23,22 +22,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
def is_same_object(instance, webhook_data, request_id):
"""
Compare the given instance to the most recent queued webhook object, returning True
if they match. This check is used to avoid creating duplicate webhook entries.
"""
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request_id == webhook_data['request_id']
)
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
m2m_changed = False
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
m2m_changed = False
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request.id == webhook_data['request_id']
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Determine the type of change being made
if kwargs.get('created'):
@@ -69,13 +78,14 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
queue = webhooks_queue.get()
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
queue[-1]['data'] = serialize_for_webhook(instance)
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else:
enqueue_object(webhook_queue, instance, request.user, request.id, action)
enqueue_object(queue, instance, request.user, request.id, action)
webhooks_queue.set(queue)
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:
@@ -84,39 +94,42 @@ def handle_changed_object(sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
request = get_request()
# 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
objectchange.save()
# Enqueue webhooks
webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
queue = webhooks_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
webhooks_queue.set(queue)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_webhooks)
def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
webhook_queue = thread_locals.webhook_queue
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()
logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
webhooks_queue.set([])
#

View File

@@ -803,6 +803,57 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
self.assertEqual(site2.custom_field_data['multiobject_field'], 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]
)
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

@@ -175,6 +175,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
queryset=ContentType.objects.filter(
model__in=VLANGROUP_SCOPE_TYPES
),
allow_null=True,
required=False,
default=None
)

View File

@@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet):
serializer_class = serializers.IPAddressSerializer
filterset_class = filtersets.IPAddressFilterSet
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class FHRPGroupViewSet(NetBoxModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')

View File

@@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service
tag = TagFilterField(model)
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):

View File

@@ -429,7 +429,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
@@ -549,6 +550,11 @@ class FHRPGroupForm(NetBoxModelForm):
fields = (
'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
)
widgets = {
'protocol': StaticSelect(),
'auth_type': StaticSelect(),
'ip_status': StaticSelect(),
}
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)

View File

@@ -8,8 +8,6 @@ from django.urls import reverse
from django.utils.functional import cached_property
from dcim.fields import ASNField
from dcim.models import Device
from netbox.models import OrganizationalModel, NetBoxModel
from ipam.choices import *
from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField
@@ -17,8 +15,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, NetBoxModel
__all__ = (
'Aggregate',
@@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
)
})
# 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,3 +0,0 @@
import threading
thread_locals = threading.local()

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 = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])

View File

@@ -24,6 +24,7 @@ AUTH_BACKEND_ATTRS = {
'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
'bitbucket': ('BitBucket', 'bitbucket'),
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
'digitalocean': ('DigitalOcean', 'digital-ocean'),

10
netbox/netbox/context.py Normal file
View File

@@ -0,0 +1,10 @@
from contextvars import ContextVar
__all__ = (
'current_request',
'webhooks_queue',
)
current_request = ContextVar('current_request', default=None)
webhooks_queue = ContextVar('webhooks_queue')

View File

@@ -1,9 +0,0 @@
from netbox import thread_locals
def set_request(request):
thread_locals.request = request
def get_request():
return getattr(thread_locals, 'request', None)

View File

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.3.6'
VERSION = '3.3.9'
# Hostname
HOSTNAME = platform.node()
@@ -81,11 +81,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_COOKIE_PATH = BASE_PATH or '/'
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@@ -130,8 +130,6 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SESSION_COOKIE_PATH = BASE_PATH or '/'
LANGUAGE_COOKIE_PATH = BASE_PATH or '/'
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -407,6 +405,7 @@ STATIC_URL = f'/{BASE_PATH}static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'project-static', 'dist'),
os.path.join(BASE_DIR, 'project-static', 'img'),
os.path.join(BASE_DIR, 'project-static', 'js'),
('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs
)
@@ -446,6 +445,10 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}metrics',
)
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}
#
# Sentry
@@ -501,7 +504,7 @@ for param in dir(configuration):
# Force usage of PostgreSQL's JSONB field for extra data
SOCIAL_AUTH_JSONFIELD_ENABLED = True
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
#
# Django Prometheus

View File

@@ -425,6 +425,12 @@ class CustomFieldColumn(tables.Column):
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = customfield.label or customfield.name
# We can't logically sort on FK values
if customfield.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
kwargs['orderable'] = False
super().__init__(*args, **kwargs)

View File

@@ -125,9 +125,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, bool(actions))
table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if is_htmx(request):

View File

@@ -0,0 +1,72 @@
/**
* Set the color mode on the `<html/>` element and in local storage.
*
* @param mode {"dark" | "light"} NetBox Color Mode.
* @param inferred {boolean} Value is inferred from browser/system preference.
*/
function setMode(mode, inferred) {
document.documentElement.setAttribute("data-netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode-inferred", inferred);
}
/**
* Determine the best initial color mode to use prior to rendering.
*/
function initMode() {
try {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem("netbox-color-mode");
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
// Color mode is inferred from browser/system preference and not deterministically set by
// the client or server.
var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
// The color mode was previously inferred from browser/system preference, but
// the server now has a value, so we should use the server's value.
return setMode(serverMode, false);
}
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
// If the client mode is not set but the server mode is, use the server mode.
return setMode(serverMode, false);
}
if (clientMode !== null && serverMode === "unset") {
// The color mode has been set, deterministically or otherwise, and the server
// has no preference or has not been set. Use the client mode, but allow it to
/// be overridden by the server if/when a server value exists.
return setMode(clientMode, true);
}
if (
clientMode !== null &&
(serverMode === "light" || serverMode === "dark") &&
clientMode !== serverMode
) {
// If the client mode is set and is different than the server mode (which is also set),
// use the client mode over the server mode, as it should be more recent.
return setMode(clientMode, false);
}
if (clientMode === serverMode) {
// If the client and server modes match, use that value.
return setMode(clientMode, false);
}
if (preferDark && serverMode === "unset") {
// If the server mode is not set but the browser prefers dark mode, use dark mode, but
// allow it to be overridden by an explicit preference.
return setMode("dark", true);
}
if (preferLight && serverMode === "unset") {
// If the server mode is not set but the browser prefers light mode, use light mode,
// but allow it to be overridden by an explicit preference.
return setMode("light", true);
}
} catch (error) {
// In the event of an error, log it to the console and set the mode to light mode.
console.error(error);
}
return setMode("light", true);
};

View File

@@ -26,78 +26,15 @@
{# Page title #}
<title>{% block title %}Home{% endblock %} | NetBox</title>
<script
type="text/javascript"
src="{% static 'setmode.js' %}"
onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
</script>
<script type="text/javascript">
/**
* Set the color mode on the `<html/>` element and in local storage.
*
* @param mode {"dark" | "light"} NetBox Color Mode.
* @param inferred {boolean} Value is inferred from browser/system preference.
*/
function setMode(mode, inferred) {
document.documentElement.setAttribute("data-netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode-inferred", inferred);
}
/**
* Determine the best initial color mode to use prior to rendering.
*/
(function () {
try {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem("netbox-color-mode");
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
// Color mode is inferred from browser/system preference and not deterministically set by
// the client or server.
var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
// The color mode was previously inferred from browser/system preference, but
// the server now has a value, so we should use the server's value.
return setMode(serverMode, false);
}
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
// If the client mode is not set but the server mode is, use the server mode.
return setMode(serverMode, false);
}
if (clientMode !== null && serverMode === "unset") {
// The color mode has been set, deterministically or otherwise, and the server
// has no preference or has not been set. Use the client mode, but allow it to
/// be overridden by the server if/when a server value exists.
return setMode(clientMode, true);
}
if (
clientMode !== null &&
(serverMode === "light" || serverMode === "dark") &&
clientMode !== serverMode
) {
// If the client mode is set and is different than the server mode (which is also set),
// use the client mode over the server mode, as it should be more recent.
return setMode(clientMode, false);
}
if (clientMode === serverMode) {
// If the client and server modes match, use that value.
return setMode(clientMode, false);
}
if (preferDark && serverMode === "unset") {
// If the server mode is not set but the browser prefers dark mode, use dark mode, but
// allow it to be overridden by an explicit preference.
return setMode("dark", true);
}
if (preferLight && serverMode === "unset") {
// If the server mode is not set but the browser prefers light mode, use light mode,
// but allow it to be overridden by an explicit preference.
return setMode("light", true);
}
} catch (error) {
// In the event of an error, log it to the console and set the mode to light mode.
console.error(error);
}
return setMode("light", true);
initMode()
})();
window.CSRF_TOKEN = "{{ csrf_token }}";
</script>

View File

@@ -229,7 +229,7 @@
<th>Utilization</th>
</tr>
{% for powerport in object.powerports.all %}
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
<tr>
<td>{{ powerport }}</td>
<td>{{ utilization.outlet_count }}</td>
@@ -247,10 +247,15 @@
<td style="padding-left: 20px">Leg {{ leg.name }}</td>
<td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</td>
<td>{{ powerfeed.available_power|divide:3 }}VA</td>
{% with phase_available=powerfeed.available_power|divide:3 %}
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
{% endwith %}
{% if powerfeed.available_power %}
{% with phase_available=powerfeed.available_power|divide:3 %}
<td>{{ phase_available }}VA</td>
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
{% endwith %}
{% else %}
<td class="text-muted">&mdash;</td>
<td class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
{% endwith %}

View File

@@ -64,19 +64,19 @@
<h5 class="card-header">Environment</h5>
<div class="card-body">
<table class="table">
<tr id="status-cpu" class="bg-light">
<tr id="status-cpu">
<th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th>
</tr>
<tr id="status-memory" class="bg-light">
<tr id="status-memory">
<th colspan="2"><i class="mdi mdi-chip"></i> Memory</th>
</tr>
<tr id="status-temperature" class="bg-light">
<tr id="status-temperature">
<th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th>
</tr>
<tr id="status-fans" class="bg-light">
<tr id="status-fans">
<th colspan="2"><i class="mdi mdi-fan"></i> Fans</th>
</tr>
<tr id="status-power" class="bg-light">
<tr id="status-power">
<th colspan="2"><i class="mdi mdi-power"></i> Power</th>
</tr>
<tr class="napalm-table-placeholder d-none invisible">

View File

@@ -7,6 +7,10 @@
<td>Site</td>
<td>{{ terminations.0.device.site|linkify }}</td>
</tr>
<tr>
<td>Location</td>
<td>{{ terminations.0.device.location|linkify|placeholder }}</td>
</tr>
<tr>
<td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>

View File

@@ -210,7 +210,7 @@
<div class="card">
<h5 class="card-header">Wireless</h5>
<div class="card-body">
{% with peer=object.connected_endpoint %}
{% with peer=object.connected_endpoints.0 %}
<table class="table table-hover">
<thead>
<tr>

View File

@@ -4,10 +4,24 @@
{% load render_table from django_tables2 %}
{% block extra_controls %}
{% if perms.dcim.add_devicetype %}
<a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device Type
</a>
{% if perms.dcim.add_devicetype or perms.dcim.add_moduletype %}
<div class="dropdown">
<button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.dcim.add_devicetype %}
<li><a class="dropdown-item" href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}">
Add Device Type
</a></li>
{% endif %}
{% if perms.dcim.add_moduletype %}
<li><a class="dropdown-item" href="{% url 'dcim:moduletype_add' %}?manufacturer={{ object.pk }}">
Add Module Type
</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% endblock extra_controls %}

View File

@@ -77,10 +77,10 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
</li>
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
</li>
</ul>
</span>

View File

@@ -173,7 +173,7 @@
<td>{{ powerfeed|linkify }}</td>
<td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td>
<td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td>
{% with power_port=powerfeed.connected_endpoint %}
{% with power_port=powerfeed.connected_endpoints.0 %}
{% if power_port %}
<td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td>
{% else %}

View File

@@ -67,64 +67,65 @@ Context:
{% applied_filters filter_form request.GET %}
{% endif %}
{# "Select all" form #}
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint">
<form method="post" class="form col-md-12">
{% csrf_token %}
<div class="card-body">
<div class="float-end">
<form method="post" class="form form-horizontal">
{% csrf_token %}
{# "Select all" form #}
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint">
<div class="form col-md-12">
<div class="card-body">
<div class="float-end">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
</div>
</div>
</div>
{% endif %}
{# Object table controls #}
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Object table #}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
{# Form buttons #}
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% block bulk_buttons %}
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
{% endblock %}
</div>
</form>
</div>
{% endif %}
{# Object table controls #}
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Object table #}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
{# Form buttons #}
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% block bulk_buttons %}
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}
</div>
</div>
</form>
</div>

View File

@@ -25,7 +25,7 @@
<tr>
<th scope="row">Assignments</th>
<td>
<a href="{% url 'tenancy:contact_list' %}?role={{ object.slug }}">{{ assignment_count }}</a>
{{ assignment_count }}
</td>
</tr>
</table>

View File

@@ -93,6 +93,12 @@
<h2><a href="{% url 'ipam:vlan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
<p>VLANs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:l2vpn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.l2vpn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.l2vpn_count }}</a></h2>
<p>L2VPNs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'circuits:circuit_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
<p>Circuits</p>

View File

@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
from ipam.models import Aggregate, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF, ASN
from netbox.views import generic
from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster
@@ -111,6 +111,7 @@ class TenantView(generic.ObjectView):
'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
@@ -188,6 +189,8 @@ class ContactGroupView(generic.ObjectView):
contacts = Contact.objects.restrict(request.user, 'view').filter(
group=instance
).annotate(
assignment_count=count_related(ContactAssignment, 'contact')
)
contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',))
contacts_table.configure(request)
@@ -338,14 +341,18 @@ class ContactBulkImportView(generic.BulkImportView):
class ContactBulkEditView(generic.BulkEditView):
queryset = Contact.objects.all()
queryset = Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
)
filterset = filtersets.ContactFilterSet
table = tables.ContactTable
form = forms.ContactBulkEditForm
class ContactBulkDeleteView(generic.BulkDeleteView):
queryset = Contact.objects.all()
queryset = Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
)
filterset = filtersets.ContactFilterSet
table = tables.ContactTable

8
netbox/users/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals

10
netbox/users/signals.py Normal file
View File

@@ -0,0 +1,10 @@
import logging
from django.dispatch import receiver
from django.contrib.auth.signals import user_login_failed
@receiver(user_login_failed)
def log_user_login_failed(sender, credentials, request, **kwargs):
logger = logging.getLogger('netbox.auth.login')
username = credentials.get("username")
logger.info(f"Failed login attempt for username: {username}")

View File

@@ -106,7 +106,7 @@ class LoginView(View):
return self.redirect_to_next(request, logger)
else:
logger.debug("Login form validation failed")
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
return render(request, self.template_name, {
'form': form,

View File

@@ -28,13 +28,12 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
serializer = super().get_request_serializer()
if serializer is not None and self.method in self.implicit_body_methods:
writable_class = self.get_writable_class(serializer)
if writable_class is not None:
if writable_class := self.get_writable_class(serializer):
if hasattr(serializer, 'child'):
child_serializer = self.get_writable_class(serializer.child)
serializer = writable_class(child=child_serializer)
serializer = writable_class(context=serializer.context, child=child_serializer)
else:
serializer = writable_class()
serializer = writable_class(context=serializer.context)
return serializer
def get_writable_class(self, serializer):

View File

@@ -0,0 +1,21 @@
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import Deserializer, Serializer as Serializer_ # noqa
from django.utils.encoding import is_protected_type
# NOTE: Module must contain both Serializer and Deserializer
class Serializer(Serializer_):
"""
Custom extension of Django's JSON serializer to support ArrayFields (see
https://code.djangoproject.com/ticket/33974).
"""
def _value_from_field(self, obj, field):
value = field.value_from_object(obj)
# Handle ArrayFields of protected types
if type(field) is ArrayField:
if not value or is_protected_type(value[0]):
return value
return value if is_protected_type(value) else field.value_to_string(obj)

View File

@@ -8,7 +8,7 @@
<div class="form-check{% if field.errors %} has-error{% endif %}">
{{ field }}
<label for="{{ field.id_for_label }}" class="form-check-label">
{{ field.label }}
{{ label }}
</label>
</div>
{% if field.help_text %}
@@ -23,7 +23,7 @@
</div>
</div>
{% elif field|widget_type == 'textarea' and not field.label %}
{% elif field|widget_type == 'textarea' and not label %}
<div class="row mb-3">
{% if label %}
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
@@ -48,7 +48,7 @@
{% elif field|widget_type == 'slugwidget' %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
{{ field.label }}
{{ label }}
</label>
<div class="col">
<div class="input-group">
@@ -71,13 +71,13 @@
accept="{{ field.field.widget.attrs.accept }}"
{% if field.is_required %}required{% endif %}
/>
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
<label for="{{ field.id_for_label }}" class="input-group-text">{{ label|bettertitle }}</label>
</div>
{% elif field|widget_type == 'clearablefileinput' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col col-md-9">
{{ field }}
@@ -87,7 +87,7 @@
{% elif field|widget_type == 'selectmultiple' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col col-md-9">
{{ field }}
@@ -103,7 +103,7 @@
{% else %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
{{ label }}
</label>
<div class="col">
{{ field }}
@@ -112,7 +112,7 @@
{% endif %}
<div class="invalid-feedback">
{% if field.field.required %}
<strong>{{ field.label }}</strong> field is required.
<strong>{{ label }}</strong> field is required.
{% endif %}
</div>
{% if bulk_nullable %}

View File

@@ -40,7 +40,7 @@ def render_field(field, bulk_nullable=False, label=None):
"""
return {
'field': field,
'label': label,
'label': label or field.label,
'bulk_nullable': bulk_nullable,
}

View File

@@ -215,6 +215,7 @@ def status_from_tag(tag: str = "info") -> str:
'warning': 'warning',
'success': 'success',
'error': 'danger',
'danger': 'danger',
'debug': 'info',
'info': 'info',
}

View File

@@ -410,6 +410,7 @@ def copy_safe_request(request):
}
return NetBoxFakeRequest({
'META': meta,
'COOKIES': request.COOKIES,
'POST': request.POST,
'GET': request.GET,
'FILES': request.FILES,

View File

@@ -9,9 +9,9 @@ django-pglocks==1.0.4
django-prometheus==2.2.0
django-redis==5.2.0
django-rich==1.4.0
django-rq==2.5.1
django-rq==2.6.0
django-tables2==2.4.1
django-taggit==3.0.0
django-taggit==3.1.0
django-timezone-field==5.0
djangorestframework==3.14.0
drf-yasg[validation]==1.21.4
@@ -19,21 +19,18 @@ graphene-django==2.15.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==8.5.7
mkdocs-material==8.5.11
mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0
Pillow==9.2.0
Pillow==9.3.0
psycopg2-binary==2.9.5
PyYAML==6.0
sentry-sdk==1.10.1
sentry-sdk==1.11.1
social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3
tablib==3.2.1
tzdata==2022.5
tzdata==2022.7
# Workaround for #7401
jsonschema==3.2.0
# Temporary fix for #10712
swagger-spec-validator==2.7.6