mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 01:48:38 -06:00
Merge pull request #6172 from netbox-community/develop
Release v2.10.10
This commit is contained in:
commit
6c1c695616
5
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -56,8 +56,3 @@ body:
|
|||||||
placeholder: "A TypeError exception was raised"
|
placeholder: "A TypeError exception was raised"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### Additional information
|
|
||||||
You can use the space below to provide any additional information or to attach files.
|
|
||||||
|
@ -33,8 +33,3 @@ body:
|
|||||||
description: "Describe the proposed changes and why they are necessary"
|
description: "Describe the proposed changes and why they are necessary"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### Additional information
|
|
||||||
You can use the space below to provide any additional information or to attach files.
|
|
||||||
|
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -51,8 +51,3 @@ body:
|
|||||||
description: "List any new dependencies on external libraries or services that this
|
description: "List any new dependencies on external libraries or services that this
|
||||||
new feature would introduce. For example, does the proposal require the installation
|
new feature would introduce. For example, does the proposal require the installation
|
||||||
of a new Python package? (Not all new features introduce new dependencies.)"
|
of a new Python package? (Not all new features introduce new dependencies.)"
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### Additional information
|
|
||||||
You can use the space below to provide any additional information or to attach files.
|
|
||||||
|
5
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
5
.github/ISSUE_TEMPLATE/housekeeping.yaml
vendored
@ -20,8 +20,3 @@ body:
|
|||||||
description: "Please provide justification for the proposed change(s)."
|
description: "Please provide justification for the proposed change(s)."
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### Additional information
|
|
||||||
You can use the space below to provide any additional information or to attach files.
|
|
||||||
|
30
.github/stale.yml
vendored
30
.github/stale.yml
vendored
@ -1,30 +0,0 @@
|
|||||||
# Configuration for Stale (https://github.com/apps/stale)
|
|
||||||
|
|
||||||
# Number of days of inactivity before an issue becomes stale
|
|
||||||
daysUntilStale: 45
|
|
||||||
|
|
||||||
# Number of days of inactivity before a stale issue is closed
|
|
||||||
daysUntilClose: 15
|
|
||||||
|
|
||||||
# Issues with these labels will never be considered stale
|
|
||||||
exemptLabels:
|
|
||||||
- "status: accepted"
|
|
||||||
- "status: blocked"
|
|
||||||
- "status: needs milestone"
|
|
||||||
|
|
||||||
# Label to use when marking an issue as stale
|
|
||||||
staleLabel: "pending closure"
|
|
||||||
|
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
|
||||||
markComment: >
|
|
||||||
This issue has been automatically marked as stale because it has not had
|
|
||||||
recent activity. It will be closed if no further activity occurs. NetBox
|
|
||||||
is governed by a small group of core maintainers which means not all opened
|
|
||||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
|
||||||
|
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
|
||||||
closeComment: >
|
|
||||||
This issue has been automatically closed due to lack of activity. In an
|
|
||||||
effort to reduce noise, please do not comment any further. Note that the
|
|
||||||
core maintainers may elect to reopen this issue at a later date if deemed
|
|
||||||
necessary.
|
|
34
.github/workflows/stale.yml
vendored
Normal file
34
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
|
||||||
|
name: 'Close stale issues/PRs'
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v3
|
||||||
|
with:
|
||||||
|
close-issue-message: >
|
||||||
|
This issue has been automatically closed due to lack of activity. In an
|
||||||
|
effort to reduce noise, please do not comment any further. Note that the
|
||||||
|
core maintainers may elect to reopen this issue at a later date if deemed
|
||||||
|
necessary.
|
||||||
|
close-pr-message: >
|
||||||
|
This PR has been automatically closed due to lack of activity.
|
||||||
|
days-before-stale: 45
|
||||||
|
days-before-close: 15
|
||||||
|
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
||||||
|
remove-stale-when-updated: false
|
||||||
|
stale-issue-label: 'pending closure'
|
||||||
|
stale-issue-message: >
|
||||||
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
recent activity. It will be closed if no further activity occurs. NetBox
|
||||||
|
is governed by a small group of core maintainers which means not all opened
|
||||||
|
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||||
|
stale-pr-label: 'pending closure'
|
||||||
|
stale-pr-message: >
|
||||||
|
This PR has been automatically marked as stale because it has not had
|
||||||
|
recent activity. It will be closed automatically if no further action is
|
||||||
|
taken.
|
@ -1,5 +1,27 @@
|
|||||||
# NetBox v2.10
|
# NetBox v2.10
|
||||||
|
|
||||||
|
## v2.10.10 (2021-04-15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#5796](https://github.com/netbox-community/netbox/issues/5796) - Add DC terminal power port, outlet types
|
||||||
|
* [#5980](https://github.com/netbox-community/netbox/issues/5980) - Add Saf-D-Grid power port, outlet types
|
||||||
|
* [#6157](https://github.com/netbox-community/netbox/issues/6157) - Support Markdown rendering for report logs
|
||||||
|
* [#6160](https://github.com/netbox-community/netbox/issues/6160) - Add F connector port type
|
||||||
|
* [#6168](https://github.com/netbox-community/netbox/issues/6168) - Add SFP56 50GE interface type
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
|
||||||
|
* [#5643](https://github.com/netbox-community/netbox/issues/5643) - Fix VLAN assignment when editing VM interfaces in bulk
|
||||||
|
* [#5652](https://github.com/netbox-community/netbox/issues/5652) - Update object data when renaming a custom field
|
||||||
|
* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
|
||||||
|
* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
|
||||||
|
* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
|
||||||
|
* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.10.9 (2021-04-12)
|
## v2.10.9 (2021-04-12)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
|
|||||||
|
|
||||||
### Creating a New Object
|
### Creating a New Object
|
||||||
|
|
||||||
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/index.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
|
To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
|
@ -314,6 +314,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||||
TYPE_USB_3_B = 'usb-3-b'
|
TYPE_USB_3_B = 'usb-3-b'
|
||||||
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
||||||
|
# Direct current (DC)
|
||||||
|
TYPE_DC = 'dc-terminal'
|
||||||
|
# Proprietary
|
||||||
|
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
('IEC 60320', (
|
('IEC 60320', (
|
||||||
@ -414,6 +418,12 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||||
)),
|
)),
|
||||||
|
('DC', (
|
||||||
|
(TYPE_DC, 'DC Terminal'),
|
||||||
|
)),
|
||||||
|
('Proprietary', (
|
||||||
|
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -507,8 +517,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_USB_A = 'usb-a'
|
TYPE_USB_A = 'usb-a'
|
||||||
TYPE_USB_MICROB = 'usb-micro-b'
|
TYPE_USB_MICROB = 'usb-micro-b'
|
||||||
TYPE_USB_C = 'usb-c'
|
TYPE_USB_C = 'usb-c'
|
||||||
|
# Direct current (DC)
|
||||||
|
TYPE_DC = 'dc-terminal'
|
||||||
# Proprietary
|
# Proprietary
|
||||||
TYPE_HDOT_CX = 'hdot-cx'
|
TYPE_HDOT_CX = 'hdot-cx'
|
||||||
|
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
('IEC 60320', (
|
('IEC 60320', (
|
||||||
@ -602,8 +615,12 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_USB_MICROB, 'USB Micro B'),
|
(TYPE_USB_MICROB, 'USB Micro B'),
|
||||||
(TYPE_USB_C, 'USB Type C'),
|
(TYPE_USB_C, 'USB Type C'),
|
||||||
)),
|
)),
|
||||||
|
('DC', (
|
||||||
|
(TYPE_DC, 'DC Terminal'),
|
||||||
|
)),
|
||||||
('Proprietary', (
|
('Proprietary', (
|
||||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||||
|
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -645,6 +662,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
|
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
|
||||||
TYPE_10GE_X2 = '10gbase-x-x2'
|
TYPE_10GE_X2 = '10gbase-x-x2'
|
||||||
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
|
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
|
||||||
|
TYPE_50GE_SFP56 = '50gbase-x-sfp56'
|
||||||
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
|
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
|
||||||
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
|
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
|
||||||
TYPE_100GE_CFP = '100gbase-x-cfp'
|
TYPE_100GE_CFP = '100gbase-x-cfp'
|
||||||
@ -749,6 +767,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
|
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
|
||||||
(TYPE_10GE_X2, 'X2 (10GE)'),
|
(TYPE_10GE_X2, 'X2 (10GE)'),
|
||||||
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
|
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
|
||||||
|
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
|
||||||
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
|
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
|
||||||
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
|
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
|
||||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||||
@ -881,6 +900,7 @@ class PortTypeChoices(ChoiceSet):
|
|||||||
TYPE_TERA1P = 'tera-1p'
|
TYPE_TERA1P = 'tera-1p'
|
||||||
TYPE_110_PUNCH = '110-punch'
|
TYPE_110_PUNCH = '110-punch'
|
||||||
TYPE_BNC = 'bnc'
|
TYPE_BNC = 'bnc'
|
||||||
|
TYPE_F = 'f'
|
||||||
TYPE_MRJ21 = 'mrj21'
|
TYPE_MRJ21 = 'mrj21'
|
||||||
TYPE_ST = 'st'
|
TYPE_ST = 'st'
|
||||||
TYPE_SC = 'sc'
|
TYPE_SC = 'sc'
|
||||||
@ -910,6 +930,7 @@ class PortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_TERA1P, 'TERA 1P'),
|
(TYPE_TERA1P, 'TERA 1P'),
|
||||||
(TYPE_110_PUNCH, '110 Punch'),
|
(TYPE_110_PUNCH, '110 Punch'),
|
||||||
(TYPE_BNC, 'BNC'),
|
(TYPE_BNC, 'BNC'),
|
||||||
|
(TYPE_F, 'F Connector'),
|
||||||
(TYPE_MRJ21, 'MRJ21'),
|
(TYPE_MRJ21, 'MRJ21'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Count
|
|
||||||
|
|
||||||
from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||||
from tenancy.filters import TenancyFilterSet
|
from tenancy.filters import TenancyFilterSet
|
||||||
@ -447,6 +446,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||||
|
feed_leg = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PowerOutletFeedLegChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PowerOutletTemplate
|
model = PowerOutletTemplate
|
||||||
@ -454,6 +457,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=InterfaceTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InterfaceTemplate
|
model = InterfaceTemplate
|
||||||
@ -461,6 +468,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PortTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FrontPortTemplate
|
model = FrontPortTemplate
|
||||||
@ -468,6 +479,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PortTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RearPortTemplate
|
model = RearPortTemplate
|
||||||
@ -818,6 +833,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
|
|||||||
choices=PowerOutletTypeChoices,
|
choices=PowerOutletTypeChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
|
feed_leg = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PowerOutletFeedLegChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PowerOutlet
|
model = PowerOutlet
|
||||||
@ -918,6 +937,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
|||||||
|
|
||||||
|
|
||||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PortTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
@ -925,6 +948,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
|||||||
|
|
||||||
|
|
||||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PortTypeChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RearPort
|
model = RearPort
|
||||||
@ -1011,7 +1038,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class VirtualChassisFilterSet(BaseFilterSet):
|
class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
@ -1078,7 +1105,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
|||||||
return queryset.filter(qs_filter).distinct()
|
return queryset.filter(qs_filter).distinct()
|
||||||
|
|
||||||
|
|
||||||
class CableFilterSet(BaseFilterSet):
|
class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
@ -1302,6 +1329,10 @@ class PowerFeedFilterSet(
|
|||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
label='Rack (ID)',
|
label='Rack (ID)',
|
||||||
)
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=PowerFeedStatusChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
tag = TagFilter()
|
tag = TagFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -851,9 +851,8 @@ class PowerOutletTemplateTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_feed_leg(self):
|
def test_feed_leg(self):
|
||||||
# TODO: Support filtering for multiple values
|
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateTestCase(TestCase):
|
class InterfaceTemplateTestCase(TestCase):
|
||||||
@ -892,9 +891,8 @@ class InterfaceTemplateTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
# TODO: Support filtering for multiple values
|
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
|
||||||
params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_mgmt_only(self):
|
def test_mgmt_only(self):
|
||||||
params = {'mgmt_only': 'true'}
|
params = {'mgmt_only': 'true'}
|
||||||
@ -946,9 +944,8 @@ class FrontPortTemplateTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
# TODO: Support filtering for multiple values
|
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
class RearPortTemplateTestCase(TestCase):
|
class RearPortTemplateTestCase(TestCase):
|
||||||
@ -987,9 +984,8 @@ class RearPortTemplateTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
# TODO: Support filtering for multiple values
|
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_positions(self):
|
def test_positions(self):
|
||||||
params = {'positions': [1, 2]}
|
params = {'positions': [1, 2]}
|
||||||
@ -1824,9 +1820,8 @@ class PowerOutletTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_feed_leg(self):
|
def test_feed_leg(self):
|
||||||
# TODO: Support filtering for multiple values
|
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_connected(self):
|
def test_connected(self):
|
||||||
params = {'connected': True}
|
params = {'connected': True}
|
||||||
@ -2063,9 +2058,8 @@ class FrontPortTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
# TODO: Test for multiple values
|
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_description(self):
|
def test_description(self):
|
||||||
params = {'description': ['First', 'Second']}
|
params = {'description': ['First', 'Second']}
|
||||||
@ -2159,9 +2153,8 @@ class RearPortTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
# TODO: Test for multiple values
|
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_positions(self):
|
def test_positions(self):
|
||||||
params = {'positions': [1, 2]}
|
params = {'positions': [1, 2]}
|
||||||
@ -2732,9 +2725,8 @@ class PowerFeedTestCase(TestCase):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_status(self):
|
def test_status(self):
|
||||||
# TODO: Test for multiple values
|
params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]}
|
||||||
params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}
|
params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}
|
||||||
|
@ -162,6 +162,24 @@ class CustomField(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.label or self.name.replace('_', ' ').capitalize()
|
return self.label or self.name.replace('_', ' ').capitalize()
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Cache instance's original name so we can check later whether it has changed
|
||||||
|
self._name = self.name
|
||||||
|
|
||||||
|
def rename_object_data(self, old_name, new_name):
|
||||||
|
"""
|
||||||
|
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||||
|
"""
|
||||||
|
for ct in self.content_types.all():
|
||||||
|
model = ct.model_class()
|
||||||
|
params = {f'custom_field_data__{old_name}__isnull': False}
|
||||||
|
instances = model.objects.filter(**params)
|
||||||
|
for instance in instances:
|
||||||
|
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||||
|
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||||
|
|
||||||
def remove_stale_data(self, content_types):
|
def remove_stale_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||||
|
@ -4,7 +4,8 @@ from datetime import timedelta
|
|||||||
from cacheops.signals import cache_invalidated, cache_read
|
from cacheops.signals import cache_invalidated, cache_read
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models.signals import m2m_changed, pre_delete
|
from django.db import DEFAULT_DB_ALIAS
|
||||||
|
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||||
from prometheus_client import Counter
|
from prometheus_client import Counter
|
||||||
@ -52,7 +53,7 @@ def _handle_changed_object(request, sender, instance, **kwargs):
|
|||||||
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
|
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
|
||||||
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
|
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
|
||||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||||
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||||
|
|
||||||
|
|
||||||
def _handle_deleted_object(request, sender, instance, **kwargs):
|
def _handle_deleted_object(request, sender, instance, **kwargs):
|
||||||
@ -85,6 +86,14 @@ def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
|||||||
instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
|
instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_cf_renamed(instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle the renaming of custom field data on objects when a CustomField is renamed.
|
||||||
|
"""
|
||||||
|
if not created and instance.name != instance._name:
|
||||||
|
instance.rename_object_data(old_name=instance._name, new_name=instance.name)
|
||||||
|
|
||||||
|
|
||||||
def handle_cf_deleted(instance, **kwargs):
|
def handle_cf_deleted(instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Handle the cleanup of old custom field data when a CustomField is deleted.
|
Handle the cleanup of old custom field data when a CustomField is deleted.
|
||||||
@ -93,6 +102,7 @@ def handle_cf_deleted(instance, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||||
|
post_save.connect(handle_cf_renamed, sender=CustomField)
|
||||||
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
||||||
|
|
||||||
|
|
||||||
|
@ -91,6 +91,33 @@ class CustomFieldTest(TestCase):
|
|||||||
# Delete the custom field
|
# Delete the custom field
|
||||||
cf.delete()
|
cf.delete()
|
||||||
|
|
||||||
|
def test_rename_customfield(self):
|
||||||
|
obj_type = ContentType.objects.get_for_model(Site)
|
||||||
|
FIELD_DATA = 'abc'
|
||||||
|
|
||||||
|
# Create a custom field
|
||||||
|
cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
|
||||||
|
cf.save()
|
||||||
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
|
# Assign custom field data to an object
|
||||||
|
site = Site.objects.create(
|
||||||
|
name='Site 1',
|
||||||
|
slug='site-1',
|
||||||
|
custom_field_data={'field1': FIELD_DATA}
|
||||||
|
)
|
||||||
|
site.refresh_from_db()
|
||||||
|
self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
|
||||||
|
|
||||||
|
# Rename the custom field
|
||||||
|
cf.name = 'field2'
|
||||||
|
cf.save()
|
||||||
|
|
||||||
|
# Check that custom field data on the object has been updated
|
||||||
|
site.refresh_from_db()
|
||||||
|
self.assertNotIn('field1', site.custom_field_data)
|
||||||
|
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldManagerTest(TestCase):
|
class CustomFieldManagerTest(TestCase):
|
||||||
|
|
||||||
|
@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||||||
class IPAMConfig(AppConfig):
|
class IPAMConfig(AppConfig):
|
||||||
name = "ipam"
|
name = "ipam"
|
||||||
verbose_name = "IPAM"
|
verbose_name = "IPAM"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import ipam.signals
|
||||||
|
21
netbox/ipam/signals.py
Normal file
21
netbox/ipam/signals.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from django.db.models.signals import pre_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from dcim.models import Device
|
||||||
|
from virtualization.models import VirtualMachine
|
||||||
|
from .models import IPAddress
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=IPAddress)
|
||||||
|
def clear_primary_ip(instance, **kwargs):
|
||||||
|
"""
|
||||||
|
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
|
||||||
|
was a primary IP.
|
||||||
|
"""
|
||||||
|
field_name = f'primary_ip{instance.family}'
|
||||||
|
device = Device.objects.filter(**{field_name: instance}).first()
|
||||||
|
if device:
|
||||||
|
device.save()
|
||||||
|
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
|
||||||
|
if virtualmachine:
|
||||||
|
virtualmachine.save()
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '2.10.9'
|
VERSION = '2.10.10'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
|
@ -66,9 +66,11 @@
|
|||||||
<a href="{{ url }}">{{ obj }}</a>
|
<a href="{{ url }}">{{ obj }}</a>
|
||||||
{% elif obj %}
|
{% elif obj %}
|
||||||
{{ obj }}
|
{{ obj }}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ message }}</td>
|
<td class="rendered-markdown">{{ message|render_markdown }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -756,6 +756,26 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
|||||||
# Add current site to VLANs query params
|
# Add current site to VLANs query params
|
||||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||||
|
else:
|
||||||
|
# See 5643
|
||||||
|
if 'pk' in self.initial:
|
||||||
|
site = None
|
||||||
|
interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
|
||||||
|
'virtual_machine__cluster__site'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||||
|
# loop or reset back to no site and break the loop.
|
||||||
|
for interface in interfaces:
|
||||||
|
if site is None:
|
||||||
|
site = interface.virtual_machine.cluster.site
|
||||||
|
elif interface.virtual_machine.cluster.site is not site:
|
||||||
|
site = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if site is not None:
|
||||||
|
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||||
|
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||||
|
|
||||||
|
|
||||||
class VMInterfaceBulkRenameForm(BulkRenameForm):
|
class VMInterfaceBulkRenameForm(BulkRenameForm):
|
||||||
@ -765,7 +785,7 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class VMInterfaceFilterForm(forms.Form):
|
class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
|
||||||
model = VMInterface
|
model = VMInterface
|
||||||
cluster_id = DynamicModelMultipleChoiceField(
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
|
Loading…
Reference in New Issue
Block a user