Merge branch 'feature' of https://github.com/netbox-community/netbox into feature

# Conflicts:
#	netbox/templates/generic/object_list.html
This commit is contained in:
checktheroads 2021-04-19 19:40:15 -07:00
commit c459e774e4
26 changed files with 405 additions and 249 deletions

View File

@ -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. 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 ### Features Matrix
* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log * [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 * [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 * [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 * [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 * [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
* Nesting - These models can be nested recursively to create a hierarchy * Nesting - These models can be nested recursively to create a hierarchy

View File

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

View File

@ -1,13 +1,24 @@
# NetBox v2.10 # 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 ### Bug Fixes
* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP * [#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 * [#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 * [#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 * [#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)
--- ---

View File

@ -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) * [#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 * [#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 * [#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
--- ---

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import * from circuits.models import *
from dcim.models import Cable, Interface, Site 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): class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -21,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003), 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 = { cls.form_data = {
'name': 'Provider X', 'name': 'Provider X',
@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), 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 = { cls.form_data = {
'cid': 'Circuit X', 'cid': 'Circuit X',
@ -157,7 +157,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ProviderNetwork(name='Provider Network 3', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Provider Network X', 'name': 'Provider Network X',

View File

@ -337,6 +337,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', (
@ -437,6 +441,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'),
)),
) )
@ -530,8 +540,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', (
@ -625,8 +638,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'),
)), )),
) )
@ -668,6 +685,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'
@ -772,6 +790,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)'),
@ -904,6 +923,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'
@ -933,6 +953,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'),
), ),
), ),

View File

@ -498,6 +498,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
@ -505,6 +509,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
@ -512,6 +520,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
@ -519,6 +531,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
@ -895,6 +911,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
@ -1000,6 +1020,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
@ -1007,6 +1031,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
@ -1423,6 +1451,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:

View File

@ -940,9 +940,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):
@ -981,9 +980,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'}
@ -1035,9 +1033,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):
@ -1076,9 +1073,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]}
@ -1985,9 +1981,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}
@ -2284,9 +2279,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']}
@ -2396,9 +2390,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]}
@ -3045,9 +3038,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}

View File

@ -12,7 +12,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from ipam.models import VLAN 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): 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]), 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 = { cls.form_data = {
'name': 'Site X', 'name': 'Site X',
@ -242,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), 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 = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
@ -298,7 +298,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Rack(name='Rack 3', site=sites[0]), Rack(name='Rack 3', site=sites[0]),
)) ))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Rack X', 'name': 'Rack X',
@ -413,7 +413,7 @@ class DeviceTypeTestCase(
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]), 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 = { cls.form_data = {
'manufacturer': manufacturers[1].pk, '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]), 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 = { cls.form_data = {
'device_type': devicetypes[1].pk, 'device_type': devicetypes[1].pk,
@ -1201,7 +1201,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsolePort(device=device, name='Console Port 3'), ConsolePort(device=device, name='Console Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1259,7 +1259,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsoleServerPort(device=device, name='Console Server Port 3'), ConsoleServerPort(device=device, name='Console Server Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1315,7 +1315,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerPort(device=device, name='Power Port 3'), PowerPort(device=device, name='Power Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1383,7 +1383,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), 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 = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1452,7 +1452,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1539,7 +1539,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), 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 = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1600,7 +1600,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
RearPort(device=device, name='Rear Port 3'), RearPort(device=device, name='Rear Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
@ -1661,7 +1661,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
DeviceBay(device=device, name='Device Bay 3'), DeviceBay(device=device, name='Device Bay 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, '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 2')
InventoryItem.objects.create(device=device, name='Inventory Item 3') 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 = { cls.form_data = {
'device': device.pk, '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[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], 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) interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = { cls.form_data = {
@ -1918,7 +1918,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), 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 = { cls.form_data = {
'site': sites[1].pk, 'site': sites[1].pk,
@ -1966,7 +1966,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), 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 = { cls.form_data = {
'name': 'Power Feed X', 'name': 'Power Feed X',

View File

@ -1172,14 +1172,20 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
class DeviceRoleBulkEditView(generic.BulkEditView): 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 filterset = filters.DeviceRoleFilterSet
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
form = forms.DeviceRoleBulkEditForm form = forms.DeviceRoleBulkEditForm
class DeviceRoleBulkDeleteView(generic.BulkDeleteView): 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 table = tables.DeviceRoleTable

View File

@ -114,6 +114,24 @@ class CustomField(BigIDModel):
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

View File

@ -391,6 +391,8 @@ class ImageAttachment(BigIDModel):
# Journal entries # Journal entries
# #
@extras_features('webhooks')
class JournalEntry(ChangeLoggedModel): class JournalEntry(ChangeLoggedModel):
""" """
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to A historical remark concerning an object; collectively, these form an object's journal. The journal is used to

View File

@ -5,7 +5,7 @@ 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 import DEFAULT_DB_ALIAS 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.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
@ -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)) 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.
@ -106,6 +114,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)

View File

@ -7,7 +7,7 @@ from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField, ObjectChange, Tag from extras.models import CustomField, ObjectChange, Tag
from utilities.testing import APITestCase 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 from utilities.testing.views import ModelViewTestCase
@ -38,7 +38,7 @@ class ChangeLogViewTest(ModelViewTestCase):
cf_select.content_types.set([ct]) cf_select.content_types.set([ct])
def test_create_object(self): def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2') tags = create_tags('Tag 1', 'Tag 2')
form_data = { form_data = {
'name': 'Site 1', 'name': 'Site 1',
'slug': 'site-1', 'slug': 'site-1',
@ -72,7 +72,7 @@ class ChangeLogViewTest(ModelViewTestCase):
def test_update_object(self): def test_update_object(self):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() 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') site.tags.set('Tag 1', 'Tag 2')
form_data = { form_data = {
@ -116,7 +116,7 @@ class ChangeLogViewTest(ModelViewTestCase):
} }
) )
site.save() site.save()
self.create_tags('Tag 1', 'Tag 2') create_tags('Tag 1', 'Tag 2')
site.tags.set('Tag 1', 'Tag 2') site.tags.set('Tag 1', 'Tag 2')
request = { request = {

View File

@ -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):

View File

@ -2,7 +2,7 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from dcim.models import Site from dcim.models import Site
from utilities.testing import APITestCase from utilities.testing import APITestCase, create_tags
class TaggedItemTest(APITestCase): 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). Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
""" """
def test_create_tagged_item(self): def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz") tags = create_tags("Foo", "Bar", "Baz")
data = { data = {
'name': 'Test Site', 'name': 'Test Site',
'slug': 'test-site', 'slug': 'test-site',
@ -37,7 +37,7 @@ class TaggedItemTest(APITestCase):
slug='test-site' slug='test-site'
) )
site.tags.add("Foo", "Bar", "Baz") site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag") create_tags("New Tag")
data = { data = {
'tags': [ 'tags': [
{"name": "Foo"}, {"name": "Foo"},

View File

@ -6,7 +6,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import * from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases, create_tags
class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -27,7 +27,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
VRF(name='VRF 3', rd='65000:3'), VRF(name='VRF 3', rd='65000:3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'VRF X', 'name': 'VRF X',
@ -64,7 +64,7 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
route_targets = ( route_targets = (
RouteTarget(name='65000:1001', tenant=tenants[0]), 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]), 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 = { cls.form_data = {
'prefix': IPNetwork('10.99.0.0/16'), '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]), 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 = { cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'), '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]), 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 = { cls.form_data = {
'vrf': vrfs[1].pk, '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]), 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 = { cls.form_data = {
'site': sites[1].pk, 'site': sites[1].pk,
@ -434,7 +434,7 @@ class ServiceTestCase(
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), 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 = { cls.form_data = {
'device': device.pk, 'device': device.pk,

View File

@ -73,9 +73,11 @@
<a href="{{ url }}">{{ obj }}</a> <a href="{{ url }}">{{ obj }}</a>
{% elif obj %} {% elif obj %}
{{ obj }} {{ obj }}
{% else %}
<span class="muted">&mdash;</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ message }}</td> <td class="rendered-markdown">{{ message|render_markdown }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}

View File

@ -1,5 +1,5 @@
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases, create_tags
class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@ -53,7 +53,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), 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 = { cls.form_data = {
'name': 'Tenant X', 'name': 'Tenant X',

View File

@ -1,3 +1,4 @@
from .api import * from .api import *
from .base import *
from .utils import * from .utils import *
from .views import * from .views import *

View File

@ -9,8 +9,8 @@ from rest_framework.test import APIClient
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange from extras.models import ObjectChange
from users.models import ObjectPermission, Token from users.models import ObjectPermission, Token
from .base import ModelTestCase
from .utils import disable_warnings from .utils import disable_warnings
from .views import ModelTestCase
__all__ = ( __all__ = (

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

View File

@ -3,8 +3,10 @@ import re
from contextlib import contextmanager from contextlib import contextmanager
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Permission, User
from django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.models import Tag
def post_data(data): def post_data(data):
@ -59,6 +61,15 @@ def create_test_user(username='testuser', permissions=None):
return user 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): def extract_form_failures(content):
""" """
Given raw HTML content from an HTTP response, return a list of form errors. Given raw HTML content from an HTTP response, return a list of form errors.

View File

@ -1,182 +1,20 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.test import override_settings
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.urls import reverse 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.choices import ObjectChangeActionChoices
from extras.models import ObjectChange, Tag from extras.models import ObjectChange
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct from .base import ModelTestCase
from .utils import disable_warnings, extract_form_failures, post_data from .utils import disable_warnings, post_data
__all__ = ( __all__ = (
'TestCase',
'ModelTestCase',
'ModelViewTestCase', 'ModelViewTestCase',
'ViewTestCases', '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 # UI Tests
# #
@ -185,7 +23,6 @@ class ModelViewTestCase(ModelTestCase):
""" """
Base TestCase for model views. Subclass to test individual views. Base TestCase for model views. Subclass to test individual views.
""" """
def _get_base_url(self): 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 Return the base format for a URL for the test's model. Override this to test for a model which belongs

View File

@ -789,14 +789,36 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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 # Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id) self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine # Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) 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) 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): class VMInterfaceBulkRenameForm(BulkRenameForm):

View File

@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases, create_tags
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface 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]), 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 = { cls.form_data = {
'name': 'Cluster X', '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]), 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 = { cls.form_data = {
'cluster': clusters[1].pk, 'cluster': clusters[1].pk,
@ -256,7 +256,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'virtual_machine': virtualmachines[1].pk, 'virtual_machine': virtualmachines[1].pk,