mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 04:56:29 -06:00
Merge branch 'feature' of https://github.com/netbox-community/netbox into feature
# Conflicts: # netbox/templates/generic/object_list.html
This commit is contained in:
commit
ea472c844e
@ -4,13 +4,15 @@
|
||||
|
||||
A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type.
|
||||
|
||||
The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`).
|
||||
|
||||
### Features Matrix
|
||||
|
||||
* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
|
||||
* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
|
||||
* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
|
||||
* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
|
||||
* Tagging - The models can be tagged with user-defined tags
|
||||
* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
|
||||
* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
|
||||
* Nesting - These models can be nested recursively to create a hierarchy
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Provider Network
|
||||
# Provider Networks
|
||||
|
||||
This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity.
|
||||
|
||||
|
@ -1,13 +1,24 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10.10 (FUTURE)
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
|
@ -28,6 +28,8 @@
|
||||
* [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned)
|
||||
* [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list
|
||||
* [#6155](https://github.com/netbox-community/netbox/issues/6155) - Fix admin links for plugins, background tasks
|
||||
* [#6171](https://github.com/netbox-community/netbox/issues/6171) - Fix display of horizontally-scrolling object lists
|
||||
* [#6173](https://github.com/netbox-community/netbox/issues/6173) - Fix assigned device/VM count when bulk editing/deleting device roles
|
||||
|
||||
---
|
||||
|
||||
|
@ -6,7 +6,7 @@ from django.urls import reverse
|
||||
from circuits.choices import *
|
||||
from circuits.models import *
|
||||
from dcim.models import Cable, Interface, Site
|
||||
from utilities.testing import ViewTestCases, create_test_device
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
||||
|
||||
|
||||
class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@ -21,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Provider(name='Provider 3', slug='provider-3', asn=65003),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Provider X',
|
||||
@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'cid': 'Circuit X',
|
||||
@ -157,7 +157,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Provider Network X',
|
||||
|
@ -337,6 +337,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||
TYPE_USB_3_B = 'usb-3-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 = (
|
||||
('IEC 60320', (
|
||||
@ -437,6 +441,12 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||
)),
|
||||
('DC', (
|
||||
(TYPE_DC, 'DC Terminal'),
|
||||
)),
|
||||
('Proprietary', (
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -530,8 +540,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_MICROB = 'usb-micro-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
# Direct current (DC)
|
||||
TYPE_DC = 'dc-terminal'
|
||||
# Proprietary
|
||||
TYPE_HDOT_CX = 'hdot-cx'
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@ -625,8 +638,12 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_MICROB, 'USB Micro B'),
|
||||
(TYPE_USB_C, 'USB Type C'),
|
||||
)),
|
||||
('DC', (
|
||||
(TYPE_DC, 'DC Terminal'),
|
||||
)),
|
||||
('Proprietary', (
|
||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
)
|
||||
|
||||
@ -668,6 +685,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
|
||||
TYPE_10GE_X2 = '10gbase-x-x2'
|
||||
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
|
||||
TYPE_50GE_SFP56 = '50gbase-x-sfp56'
|
||||
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
|
||||
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
|
||||
TYPE_100GE_CFP = '100gbase-x-cfp'
|
||||
@ -772,6 +790,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
|
||||
(TYPE_10GE_X2, 'X2 (10GE)'),
|
||||
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
|
||||
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
|
||||
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
|
||||
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
|
||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||
@ -904,6 +923,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_TERA1P = 'tera-1p'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_F = 'f'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
TYPE_ST = 'st'
|
||||
TYPE_SC = 'sc'
|
||||
@ -933,6 +953,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_TERA1P, 'TERA 1P'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_F, 'F Connector'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
),
|
||||
),
|
||||
|
@ -498,6 +498,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
feed_leg = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
@ -505,6 +509,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@ -512,6 +520,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
@ -519,6 +531,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
@ -895,6 +911,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
|
||||
choices=PowerOutletTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
feed_leg = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
@ -1000,6 +1020,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
@ -1007,6 +1031,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
@ -1423,6 +1451,10 @@ class PowerFeedFilterSet(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerFeedStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
|
@ -940,9 +940,8 @@ class PowerOutletTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_feed_leg(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class InterfaceTemplateTestCase(TestCase):
|
||||
@ -981,9 +980,8 @@ class InterfaceTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mgmt_only(self):
|
||||
params = {'mgmt_only': 'true'}
|
||||
@ -1035,9 +1033,8 @@ class FrontPortTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RearPortTemplateTestCase(TestCase):
|
||||
@ -1076,9 +1073,8 @@ class RearPortTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_positions(self):
|
||||
params = {'positions': [1, 2]}
|
||||
@ -1985,9 +1981,8 @@ class PowerOutletTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_feed_leg(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
@ -2284,9 +2279,8 @@ class FrontPortTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
@ -2396,9 +2390,8 @@ class RearPortTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_positions(self):
|
||||
params = {'positions': [1, 2]}
|
||||
@ -3045,9 +3038,8 @@ class PowerFeedTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}
|
||||
|
@ -12,7 +12,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import ViewTestCases, create_test_device
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
||||
|
||||
|
||||
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
@ -109,7 +109,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Site X',
|
||||
@ -242,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'rack': rack.pk,
|
||||
@ -298,7 +298,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Rack(name='Rack 3', site=sites[0]),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Rack X',
|
||||
@ -413,7 +413,7 @@ class DeviceTypeTestCase(
|
||||
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
@ -1021,7 +1021,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
@ -1201,7 +1201,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
ConsolePort(device=device, name='Console Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1259,7 +1259,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
ConsoleServerPort(device=device, name='Console Server Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1315,7 +1315,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
PowerPort(device=device, name='Power Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1383,7 +1383,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1452,7 +1452,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1539,7 +1539,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1600,7 +1600,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
RearPort(device=device, name='Rear Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1661,7 +1661,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
DeviceBay(device=device, name='Device Bay 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1701,7 +1701,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 2')
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 3')
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
@ -1791,7 +1791,7 @@ class CableTestCase(
|
||||
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
|
||||
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
interface_ct = ContentType.objects.get_for_model(Interface)
|
||||
cls.form_data = {
|
||||
@ -1918,7 +1918,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'site': sites[1].pk,
|
||||
@ -1966,7 +1966,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Power Feed X',
|
||||
|
@ -1172,14 +1172,20 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class DeviceRoleBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
filterset = filters.DeviceRoleFilterSet
|
||||
table = tables.DeviceRoleTable
|
||||
form = forms.DeviceRoleBulkEditForm
|
||||
|
||||
|
||||
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
|
@ -114,6 +114,24 @@ class CustomField(BigIDModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
|
@ -391,6 +391,8 @@ class ImageAttachment(BigIDModel):
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class JournalEntry(ChangeLoggedModel):
|
||||
"""
|
||||
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
|
||||
|
@ -5,7 +5,7 @@ from cacheops.signals import cache_invalidated, cache_read
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.db.models.signals import m2m_changed, pre_delete
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from prometheus_client import Counter
|
||||
@ -98,6 +98,14 @@ def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
||||
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):
|
||||
"""
|
||||
Handle the cleanup of old custom field data when a CustomField is deleted.
|
||||
@ -106,6 +114,7 @@ def handle_cf_deleted(instance, **kwargs):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
@ -7,7 +7,7 @@ from dcim.models import Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, ObjectChange, Tag
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing.utils import post_data
|
||||
from utilities.testing.utils import create_tags, post_data
|
||||
from utilities.testing.views import ModelViewTestCase
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
cf_select.content_types.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||
tags = create_tags('Tag 1', 'Tag 2')
|
||||
form_data = {
|
||||
'name': 'Site 1',
|
||||
'slug': 'site-1',
|
||||
@ -72,7 +72,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
def test_update_object(self):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
|
||||
tags = create_tags('Tag 1', 'Tag 2', 'Tag 3')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
|
||||
form_data = {
|
||||
@ -116,7 +116,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
self.create_tags('Tag 1', 'Tag 2')
|
||||
create_tags('Tag 1', 'Tag 2')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
|
||||
request = {
|
||||
|
@ -91,6 +91,33 @@ class CustomFieldTest(TestCase):
|
||||
# Delete the custom field
|
||||
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):
|
||||
|
||||
|
@ -2,7 +2,7 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Site
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing import APITestCase, create_tags
|
||||
|
||||
|
||||
class TaggedItemTest(APITestCase):
|
||||
@ -10,7 +10,7 @@ class TaggedItemTest(APITestCase):
|
||||
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
|
||||
"""
|
||||
def test_create_tagged_item(self):
|
||||
tags = self.create_tags("Foo", "Bar", "Baz")
|
||||
tags = create_tags("Foo", "Bar", "Baz")
|
||||
data = {
|
||||
'name': 'Test Site',
|
||||
'slug': 'test-site',
|
||||
@ -37,7 +37,7 @@ class TaggedItemTest(APITestCase):
|
||||
slug='test-site'
|
||||
)
|
||||
site.tags.add("Foo", "Bar", "Baz")
|
||||
self.create_tags("New Tag")
|
||||
create_tags("New Tag")
|
||||
data = {
|
||||
'tags': [
|
||||
{"name": "Foo"},
|
||||
|
@ -6,7 +6,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from ipam.choices import *
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import ViewTestCases
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
|
||||
|
||||
class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@ -27,7 +27,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
VRF(name='VRF 3', rd='65000:3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'VRF X',
|
||||
@ -64,7 +64,7 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
route_targets = (
|
||||
RouteTarget(name='65000:1001', tenant=tenants[0]),
|
||||
@ -141,7 +141,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'prefix': IPNetwork('10.99.0.0/16'),
|
||||
@ -226,7 +226,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'prefix': IPNetwork('192.0.2.0/24'),
|
||||
@ -277,7 +277,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'vrf': vrfs[1].pk,
|
||||
@ -374,7 +374,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'site': sites[1].pk,
|
||||
@ -434,7 +434,7 @@ class ServiceTestCase(
|
||||
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
|
@ -73,9 +73,11 @@
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ message }}</td>
|
||||
<td class="rendered-markdown">{{ message|render_markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import ViewTestCases
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
|
||||
|
||||
class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
@ -53,7 +53,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Tenant X',
|
||||
|
@ -1,3 +1,4 @@
|
||||
from .api import *
|
||||
from .base import *
|
||||
from .utils import *
|
||||
from .views import *
|
||||
|
@ -9,8 +9,8 @@ from rest_framework.test import APIClient
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.models import ObjectChange
|
||||
from users.models import ObjectPermission, Token
|
||||
from .base import ModelTestCase
|
||||
from .utils import disable_warnings
|
||||
from .views import ModelTestCase
|
||||
|
||||
|
||||
__all__ = (
|
||||
|
161
netbox/utilities/testing/base.py
Normal file
161
netbox/utilities/testing/base.py
Normal file
@ -0,0 +1,161 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models import ManyToManyField
|
||||
from django.forms.models import model_to_dict
|
||||
from django.test import Client, TestCase as _TestCase
|
||||
from netaddr import IPNetwork
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from users.models import ObjectPermission
|
||||
from utilities.permissions import resolve_permission_ct
|
||||
from .utils import extract_form_failures
|
||||
|
||||
__all__ = (
|
||||
'ModelTestCase',
|
||||
'TestCase',
|
||||
)
|
||||
|
||||
|
||||
class TestCase(_TestCase):
|
||||
user_permissions = ()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# Create the test user and assign permissions
|
||||
self.user = User.objects.create_user(username='testuser')
|
||||
self.add_permissions(*self.user_permissions)
|
||||
|
||||
# Initialize the test client
|
||||
self.client = Client()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
#
|
||||
# Permissions management
|
||||
#
|
||||
|
||||
def add_permissions(self, *names):
|
||||
"""
|
||||
Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
|
||||
"""
|
||||
for name in names:
|
||||
ct, action = resolve_permission_ct(name)
|
||||
obj_perm = ObjectPermission(name=name, actions=[action])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ct)
|
||||
|
||||
#
|
||||
# Custom assertions
|
||||
#
|
||||
|
||||
def assertHttpStatus(self, response, expected_status):
|
||||
"""
|
||||
TestCase method. Provide more detail in the event of an unexpected HTTP response.
|
||||
"""
|
||||
err_message = None
|
||||
# Construct an error message only if we know the test is going to fail
|
||||
if response.status_code != expected_status:
|
||||
if hasattr(response, 'data'):
|
||||
# REST API response; pass the response data through directly
|
||||
err = response.data
|
||||
else:
|
||||
# Attempt to extract form validation errors from the response HTML
|
||||
form_errors = extract_form_failures(response.content)
|
||||
err = form_errors or response.content or 'No data'
|
||||
err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
|
||||
self.assertEqual(response.status_code, expected_status, err_message)
|
||||
|
||||
|
||||
class ModelTestCase(TestCase):
|
||||
"""
|
||||
Parent class for TestCases which deal with models.
|
||||
"""
|
||||
model = None
|
||||
|
||||
def _get_queryset(self):
|
||||
"""
|
||||
Return a base queryset suitable for use in test methods.
|
||||
"""
|
||||
return self.model.objects.all()
|
||||
|
||||
def prepare_instance(self, instance):
|
||||
"""
|
||||
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
|
||||
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
|
||||
"""
|
||||
return instance
|
||||
|
||||
def model_to_dict(self, instance, fields, api=False):
|
||||
"""
|
||||
Return a dictionary representation of an instance.
|
||||
"""
|
||||
# Prepare the instance and call Django's model_to_dict() to extract all fields
|
||||
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
|
||||
|
||||
# Map any additional (non-field) instance attributes that were specified
|
||||
for attr in fields:
|
||||
if hasattr(instance, attr) and attr not in model_dict:
|
||||
model_dict[attr] = getattr(instance, attr)
|
||||
|
||||
for key, value in list(model_dict.items()):
|
||||
try:
|
||||
field = instance._meta.get_field(key)
|
||||
except FieldDoesNotExist:
|
||||
# Attribute is not a model field
|
||||
continue
|
||||
|
||||
# Handle ManyToManyFields
|
||||
if value and type(field) in (ManyToManyField, TaggableManager):
|
||||
|
||||
if field.related_model is ContentType:
|
||||
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
|
||||
else:
|
||||
model_dict[key] = sorted([obj.pk for obj in value])
|
||||
|
||||
if api:
|
||||
|
||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||
if type(getattr(instance, key)) is ContentType:
|
||||
ct = ContentType.objects.get(pk=value)
|
||||
model_dict[key] = f'{ct.app_label}.{ct.model}'
|
||||
|
||||
# Convert IPNetwork instances to strings
|
||||
elif type(value) is IPNetwork:
|
||||
model_dict[key] = str(value)
|
||||
|
||||
else:
|
||||
|
||||
# Convert ArrayFields to CSV strings
|
||||
if type(instance._meta.get_field(key)) is ArrayField:
|
||||
model_dict[key] = ','.join([str(v) for v in value])
|
||||
|
||||
return model_dict
|
||||
|
||||
#
|
||||
# Custom assertions
|
||||
#
|
||||
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
"""
|
||||
Compare a model instance to a dictionary, checking that its attribute values match those specified
|
||||
in the dictionary.
|
||||
|
||||
:param instance: Python object instance
|
||||
:param data: Dictionary of test data used to define the instance
|
||||
:param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
|
||||
:param api: Set to True is the data is a JSON representation of the instance
|
||||
"""
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
|
||||
fields = [k for k in data.keys() if k not in exclude]
|
||||
model_dict = self.model_to_dict(instance, fields=fields, api=api)
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes or have been excluded
|
||||
relevant_data = {
|
||||
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
|
||||
}
|
||||
|
||||
self.assertDictEqual(model_dict, relevant_data)
|
@ -3,8 +3,10 @@ import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.contrib.auth.models import Permission, User
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from extras.models import Tag
|
||||
|
||||
|
||||
def post_data(data):
|
||||
@ -59,6 +61,15 @@ def create_test_user(username='testuser', permissions=None):
|
||||
return user
|
||||
|
||||
|
||||
def create_tags(*names):
|
||||
"""
|
||||
Create and return a Tag instance for each name given.
|
||||
"""
|
||||
tags = [Tag(name=name, slug=slugify(name)) for name in names]
|
||||
Tag.objects.bulk_create(tags)
|
||||
return tags
|
||||
|
||||
|
||||
def extract_form_failures(content):
|
||||
"""
|
||||
Given raw HTML content from an HTTP response, return a list of form errors.
|
||||
|
@ -1,182 +1,20 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||
from django.db.models import ManyToManyField
|
||||
from django.forms.models import model_to_dict
|
||||
from django.test import Client, TestCase as _TestCase, override_settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from netaddr import IPNetwork
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.models import ObjectChange, Tag
|
||||
from extras.models import ObjectChange
|
||||
from users.models import ObjectPermission
|
||||
from utilities.permissions import resolve_permission_ct
|
||||
from .utils import disable_warnings, extract_form_failures, post_data
|
||||
|
||||
from .base import ModelTestCase
|
||||
from .utils import disable_warnings, post_data
|
||||
|
||||
__all__ = (
|
||||
'TestCase',
|
||||
'ModelTestCase',
|
||||
'ModelViewTestCase',
|
||||
'ViewTestCases',
|
||||
)
|
||||
|
||||
|
||||
class TestCase(_TestCase):
|
||||
user_permissions = ()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# Create the test user and assign permissions
|
||||
self.user = User.objects.create_user(username='testuser')
|
||||
self.add_permissions(*self.user_permissions)
|
||||
|
||||
# Initialize the test client
|
||||
self.client = Client()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def prepare_instance(self, instance):
|
||||
"""
|
||||
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
|
||||
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
|
||||
"""
|
||||
return instance
|
||||
|
||||
def model_to_dict(self, instance, fields, api=False):
|
||||
"""
|
||||
Return a dictionary representation of an instance.
|
||||
"""
|
||||
# Prepare the instance and call Django's model_to_dict() to extract all fields
|
||||
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
|
||||
|
||||
# Map any additional (non-field) instance attributes that were specified
|
||||
for attr in fields:
|
||||
if hasattr(instance, attr) and attr not in model_dict:
|
||||
model_dict[attr] = getattr(instance, attr)
|
||||
|
||||
for key, value in list(model_dict.items()):
|
||||
try:
|
||||
field = instance._meta.get_field(key)
|
||||
except FieldDoesNotExist:
|
||||
# Attribute is not a model field
|
||||
continue
|
||||
|
||||
# Handle ManyToManyFields
|
||||
if value and type(field) in (ManyToManyField, TaggableManager):
|
||||
|
||||
if field.related_model is ContentType:
|
||||
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
|
||||
else:
|
||||
model_dict[key] = sorted([obj.pk for obj in value])
|
||||
|
||||
if api:
|
||||
|
||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||
if type(getattr(instance, key)) is ContentType:
|
||||
ct = ContentType.objects.get(pk=value)
|
||||
model_dict[key] = f'{ct.app_label}.{ct.model}'
|
||||
|
||||
# Convert IPNetwork instances to strings
|
||||
elif type(value) is IPNetwork:
|
||||
model_dict[key] = str(value)
|
||||
|
||||
else:
|
||||
|
||||
# Convert ArrayFields to CSV strings
|
||||
if type(instance._meta.get_field(key)) is ArrayField:
|
||||
model_dict[key] = ','.join([str(v) for v in value])
|
||||
|
||||
return model_dict
|
||||
|
||||
#
|
||||
# Permissions management
|
||||
#
|
||||
|
||||
def add_permissions(self, *names):
|
||||
"""
|
||||
Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
|
||||
"""
|
||||
for name in names:
|
||||
ct, action = resolve_permission_ct(name)
|
||||
obj_perm = ObjectPermission(name=name, actions=[action])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ct)
|
||||
|
||||
#
|
||||
# Custom assertions
|
||||
#
|
||||
|
||||
def assertHttpStatus(self, response, expected_status):
|
||||
"""
|
||||
TestCase method. Provide more detail in the event of an unexpected HTTP response.
|
||||
"""
|
||||
err_message = None
|
||||
# Construct an error message only if we know the test is going to fail
|
||||
if response.status_code != expected_status:
|
||||
if hasattr(response, 'data'):
|
||||
# REST API response; pass the response data through directly
|
||||
err = response.data
|
||||
else:
|
||||
# Attempt to extract form validation errors from the response HTML
|
||||
form_errors = extract_form_failures(response.content)
|
||||
err = form_errors or response.content or 'No data'
|
||||
err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
|
||||
self.assertEqual(response.status_code, expected_status, err_message)
|
||||
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
"""
|
||||
Compare a model instance to a dictionary, checking that its attribute values match those specified
|
||||
in the dictionary.
|
||||
|
||||
:param instance: Python object instance
|
||||
:param data: Dictionary of test data used to define the instance
|
||||
:param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
|
||||
:param api: Set to True is the data is a JSON representation of the instance
|
||||
"""
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
|
||||
fields = [k for k in data.keys() if k not in exclude]
|
||||
model_dict = self.model_to_dict(instance, fields=fields, api=api)
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes or have been excluded
|
||||
relevant_data = {
|
||||
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
|
||||
}
|
||||
|
||||
self.assertDictEqual(model_dict, relevant_data)
|
||||
|
||||
#
|
||||
# Convenience methods
|
||||
#
|
||||
|
||||
@classmethod
|
||||
def create_tags(cls, *names):
|
||||
"""
|
||||
Create and return a Tag instance for each name given.
|
||||
"""
|
||||
tags = [Tag(name=name, slug=slugify(name)) for name in names]
|
||||
Tag.objects.bulk_create(tags)
|
||||
return tags
|
||||
|
||||
|
||||
class ModelTestCase(TestCase):
|
||||
"""
|
||||
Parent class for TestCases which deal with models.
|
||||
"""
|
||||
model = None
|
||||
|
||||
def _get_queryset(self):
|
||||
"""
|
||||
Return a base queryset suitable for use in test methods.
|
||||
"""
|
||||
return self.model.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# UI Tests
|
||||
#
|
||||
@ -185,7 +23,6 @@ class ModelViewTestCase(ModelTestCase):
|
||||
"""
|
||||
Base TestCase for model views. Subclass to test individual views.
|
||||
"""
|
||||
|
||||
def _get_base_url(self):
|
||||
"""
|
||||
Return the base format for a URL for the test's model. Override this to test for a model which belongs
|
||||
|
@ -789,14 +789,36 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
if 'virtual_machine' in self.initial:
|
||||
vm_id = self.initial.get('virtual_machine')
|
||||
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
|
||||
# Limit VLAN choices by virtual machine
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
# Limit VLAN choices by virtual machine
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
|
||||
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):
|
||||
|
@ -5,7 +5,7 @@ from netaddr import EUI
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import ViewTestCases
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
@ -100,7 +100,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Cluster X',
|
||||
@ -174,7 +174,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'cluster': clusters[1].pk,
|
||||
@ -256,7 +256,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'virtual_machine': virtualmachines[1].pk,
|
||||
|
Loading…
Reference in New Issue
Block a user