diff --git a/changes/573.added b/changes/573.added new file mode 100644 index 00000000..83e34338 --- /dev/null +++ b/changes/573.added @@ -0,0 +1,2 @@ +Added multi-tenancy support to ValidatedSoftwareLCM, allowing software validations to be scoped to specific Tenants. +Added Tenant filtering to Hardware Notice and Validated Software reports. \ No newline at end of file diff --git a/docs/user/software_lifecycle.md b/docs/user/software_lifecycle.md index 5454e1f2..3af21f0c 100644 --- a/docs/user/software_lifecycle.md +++ b/docs/user/software_lifecycle.md @@ -19,6 +19,7 @@ When creating the validated software the following fields are available. Fields | Valid Until | End date when the rules defined by this object stop applying | | Preferred Version | Whether the Software specified by this Validated Software should be considered a preferred version | | Devices -> Devices | Devices whose software will be validated by this Validated Software | +| Devices -> Device tenants | Devices assigned to these tenants will have software validated by this Validated Software | | Devices -> Device types | Devices having these device types will have software validated by this Validated Software | | Devices -> Device roles | Devices having these device roles will have software validated by this Validated Software | | Inventory Items -> Inventory items | Inventory items whose software will be validated by this Validated Software | @@ -35,6 +36,7 @@ Example of a Validated Software object with most fields filled in: Validated Software object can be assigned to: - devices +- device tenants - device types - device roles - inventory items @@ -54,7 +56,9 @@ For device, Validated Software will be used if one, or more, of the following, a - Device is explicitly listed in the Validated Software `devices` attribute. - Device's device type AND device role match `device_types` AND `device_roles` in Validated Software. This applies only if BOTH are set. See the **Special cases** subsection that follows. +- Device's tenant AND device type match `device_tenants` AND `device_types` in Validated Software. This applies only if BOTH are set. See the **Special cases** subsection that follows. - Device's device type is listed in the Validated Software `device_types` attribute. +- Device's tenant is listed in the Validated Software `device_tenants` attribute. - Device's role is listed in the Validated Software `device_roles` attribute. - Device's tags are listed in the Validated Software `object_tags` attribute. @@ -86,6 +90,27 @@ For example, in the below case **Validated Software 4.21M** will apply to **Devi - device roles: leaf - software: 4.21M +When a Validated Software object is assigned to both device tenants and device type then these are used in conjunction (logical AND). That is, such an object will apply to devices that are assigned both, specified device tenant AND device type. + +This logic is used to allow to specify a subset of the devices of a given type by adding additional constraint in the form of tenant. + +For example, in the below case **Validated Software 4.21M** will apply to **Device 1** only since **Device 2** has a match for device type only. + +- Device 1 + - device type: 7150-S64 + - device tenants: tenant_A + - software: 4.21M + +- Device 2 + - device type: 7150-S64 + - device tenants: tenant_B + - software: 4.21M + +- Validated Software - 4.21M: + - device types: 7150-S64 + - device tenants: tenant_A + - software: 4.21M + ### Behavior when using API to retrieve Validated Software list for devices and inventory items By default when retrieving a list of Validated Software objects it is possible to filter results by assignments used when the object was created. diff --git a/nautobot_device_lifecycle_mgmt/filter_extensions.py b/nautobot_device_lifecycle_mgmt/filter_extensions.py index fe794a73..939625b4 100644 --- a/nautobot_device_lifecycle_mgmt/filter_extensions.py +++ b/nautobot_device_lifecycle_mgmt/filter_extensions.py @@ -192,6 +192,32 @@ class TagFilterExtension(FilterExtension): } +# +# TENANT FILTER EXTENSION +# +class TenantFilterExtension(FilterExtension): + """Extends Tenant Filters.""" + + model = "tenancy.tenant" + + filterset_fields = { + "nautobot_device_lifecycle_mgmt_validated_software": NaturalKeyOrPKMultipleChoiceFilter( + field_name="validated_software_tenants", + queryset=ValidatedSoftwareLCM.objects.all(), + to_field_name="pk", + label="Validated Software", + ), + } + + filterform_fields = { + "nautobot_device_lifecycle_mgmt_validated_software": DynamicModelMultipleChoiceField( + queryset=ValidatedSoftwareLCM.objects.all(), + label="Validated Software", + required=False, + ), + } + + # # SOFTWAREVERSION FILTER EXTENSION # @@ -217,5 +243,6 @@ class SoftwareVersionFilterExtension(FilterExtension): # pylint: disable=too-fe RoleFilterExtension, DeviceTypeFilterExtension, TagFilterExtension, + TenantFilterExtension, SoftwareVersionFilterExtension, ] diff --git a/nautobot_device_lifecycle_mgmt/filters.py b/nautobot_device_lifecycle_mgmt/filters.py index 6467abd2..27841719 100644 --- a/nautobot_device_lifecycle_mgmt/filters.py +++ b/nautobot_device_lifecycle_mgmt/filters.py @@ -1,4 +1,5 @@ """Filtering implementation for the Lifecycle Management app.""" +# pylint: disable=too-many-lines import datetime @@ -7,6 +8,7 @@ from nautobot.apps.filters import NautobotFilterSet, SearchFilter, StatusFilter, StatusModelFilterSetMixin from nautobot.dcim.models import Device, DeviceType, InventoryItem, Location, Manufacturer, Platform, SoftwareVersion from nautobot.extras.models import Role, Status, Tag +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt.choices import CVESeverityChoices from nautobot_device_lifecycle_mgmt.models import ( @@ -284,6 +286,7 @@ class ValidatedSoftwareLCMFilterSet(NautobotFilterSet): "start": {"lookup_expr": "icontains", "preprocessor": str.strip}, "end": {"lookup_expr": "icontains", "preprocessor": str.strip}, "devices__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, + "device_tenants__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device_types__model": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device_roles__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, "inventory_items__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, @@ -317,6 +320,17 @@ class ValidatedSoftwareLCMFilterSet(NautobotFilterSet): to_field_name="model", label="Device Types (model)", ) + device_tenants_id = django_filters.ModelMultipleChoiceFilter( + field_name="device_tenants", + queryset=Tenant.objects.all(), + label="Tenant", + ) + device_tenants = django_filters.ModelMultipleChoiceFilter( + field_name="device_tenants__name", + queryset=Tenant.objects.all(), + to_field_name="name", + label="Tenant (name)", + ) device_roles_id = django_filters.ModelMultipleChoiceFilter( field_name="device_roles", queryset=Role.objects.all(), @@ -371,7 +385,7 @@ def valid_search(self, queryset, name, value): # pylint: disable=unused-argumen """Perform the valid_search search.""" today = datetime.date.today() if value is True: - qs_filter = Q(start__lte=today, end=None) | Q(start__lte=today, end__gte=today) + qs_filter = Q(start__lte=today, end__isnull=True) | Q(start__lte=today, end__gte=today) else: qs_filter = Q(start__gt=today) | Q(end__lt=today) return queryset.filter(qs_filter) @@ -428,6 +442,17 @@ class DeviceHardwareNoticeResultFilterSet(NautobotFilterSet): queryset=Platform.objects.all(), label="Platform", ) + tenant = django_filters.ModelMultipleChoiceFilter( + field_name="device__tenant__name", + queryset=Tenant.objects.all(), + to_field_name="name", + label="Tenant (name)", + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + field_name="device__tenant", + queryset=Tenant.objects.all(), + label="Tenant", + ) location_id = django_filters.ModelMultipleChoiceFilter( field_name="device__location", queryset=Location.objects.all(), @@ -542,6 +567,7 @@ class DeviceSoftwareValidationResultFilterSet(NautobotFilterSet): "device__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, "software__version": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device__platform__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, + "device__tenant__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device__location__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device__device_type__model": {"lookup_expr": "icontains", "preprocessor": str.strip}, "device__role__name": {"lookup_expr": "icontains", "preprocessor": str.strip}, @@ -563,6 +589,17 @@ class DeviceSoftwareValidationResultFilterSet(NautobotFilterSet): queryset=Platform.objects.all(), label="Platform", ) + tenant = django_filters.ModelMultipleChoiceFilter( + field_name="device__tenant__name", + queryset=Tenant.objects.all(), + to_field_name="name", + label="Tenant (name)", + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + field_name="device__tenant", + queryset=Tenant.objects.all(), + label="Tenant", + ) location_id = django_filters.ModelMultipleChoiceFilter( field_name="device__location", queryset=Location.objects.all(), diff --git a/nautobot_device_lifecycle_mgmt/forms.py b/nautobot_device_lifecycle_mgmt/forms.py index 732d74e0..b4b166c9 100644 --- a/nautobot_device_lifecycle_mgmt/forms.py +++ b/nautobot_device_lifecycle_mgmt/forms.py @@ -1,4 +1,5 @@ """Forms implementation for the Lifecycle Management app.""" +# pylint: disable=too-many-lines import logging @@ -24,6 +25,7 @@ from nautobot.core.forms.constants import BOOLEAN_WITH_BLANK_CHOICES from nautobot.dcim.models import Device, DeviceType, InventoryItem, Location, Manufacturer, Platform, SoftwareVersion from nautobot.extras.models import Role, Status, Tag +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt.choices import ( ContractTypeChoices, @@ -173,6 +175,7 @@ class ValidatedSoftwareLCMForm(NautobotModelForm): software = DynamicModelChoiceField(queryset=SoftwareVersion.objects.all(), required=True) devices = DynamicModelMultipleChoiceField(queryset=Device.objects.all(), required=False) + device_tenants = DynamicModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False) device_types = DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), required=False) device_roles = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), query_params={"content_types": "dcim.device"}, required=False @@ -192,6 +195,7 @@ class Meta: fields = [ # pylint: disable=E4271 "software", "devices", + "device_tenants", "device_types", "device_roles", "inventory_items", @@ -211,12 +215,19 @@ def clean(self): super().clean() devices = self.cleaned_data.get("devices") + device_tenants = self.cleaned_data.get("device_tenants") device_types = self.cleaned_data.get("device_types") device_roles = self.cleaned_data.get("device_roles") inventory_items = self.cleaned_data.get("inventory_items") object_tags = self.cleaned_data.get("object_tags") - if sum(obj.count() for obj in (devices, device_types, device_roles, inventory_items, object_tags)) == 0: + if ( + sum( + obj.count() + for obj in (devices, device_tenants, device_types, device_roles, inventory_items, object_tags) + ) + == 0 + ): msg = "You need to assign to at least one object." self.add_error(None, msg) @@ -242,6 +253,11 @@ class ValidatedSoftwareLCMBulkEditForm(NautobotBulkEditForm): required=False, label="Devices", ) + device_tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + label="Tenant", + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, @@ -285,6 +301,7 @@ class Meta: nullable_fields = [ "devices", + "device_tenants", "device_types", "device_roles", "inventory_items", @@ -308,6 +325,11 @@ class ValidatedSoftwareLCMFilterForm(NautobotFilterForm): queryset=Device.objects.all(), required=False, ) + device_tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + to_field_name="name", + required=False, + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), to_field_name="model", @@ -338,6 +360,7 @@ class Meta: "q", "software", "devices", + "device_tenants", "device_types", "device_roles", "inventory_items", @@ -368,6 +391,12 @@ class DeviceHardwareNoticeResultFilterForm(NautobotFilterForm): label="Platform", required=False, ) + tenant = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + label="Tenant", + to_field_name="name", + required=False, + ) location = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), query_params={"content_type": "dcim.device"}, @@ -422,6 +451,7 @@ class Meta: "supported", "platform", "location", + "tenant", "device", "device_status", "device_type", @@ -452,6 +482,12 @@ class DeviceSoftwareValidationResultFilterForm(NautobotFilterForm): label="Platform", required=False, ) + tenant = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + label="Tenant", + to_field_name="name", + required=False, + ) valid = forms.BooleanField( required=False, widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES), @@ -498,6 +534,7 @@ class Meta: "software", "valid", "platform", + "tenant", "location", "device", "device_type", diff --git a/nautobot_device_lifecycle_mgmt/migrations/0030_validatedsoftwarelcm_device_tenant.py b/nautobot_device_lifecycle_mgmt/migrations/0030_validatedsoftwarelcm_device_tenant.py new file mode 100644 index 00000000..4ffe65e2 --- /dev/null +++ b/nautobot_device_lifecycle_mgmt/migrations/0030_validatedsoftwarelcm_device_tenant.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2026-03-13 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0009_update_all_charfields_max_length_to_255"), + ("nautobot_device_lifecycle_mgmt", "0029_contractlcm_status"), + ] + + operations = [ + migrations.AddField( + model_name="validatedsoftwarelcm", + name="device_tenants", + field=models.ManyToManyField(blank=True, related_name="validated_software_tenants", to="tenancy.tenant"), + ), + ] diff --git a/nautobot_device_lifecycle_mgmt/models.py b/nautobot_device_lifecycle_mgmt/models.py index 38602551..a3e92820 100644 --- a/nautobot_device_lifecycle_mgmt/models.py +++ b/nautobot_device_lifecycle_mgmt/models.py @@ -298,6 +298,7 @@ class ValidatedSoftwareLCM(PrimaryModel): ) devices = models.ManyToManyField(to="dcim.Device", related_name="validated_software", blank=True) device_types = models.ManyToManyField(to="dcim.DeviceType", related_name="validated_software", blank=True) + device_tenants = models.ManyToManyField(to="tenancy.Tenant", related_name="validated_software_tenants", blank=True) device_roles = models.ManyToManyField(to="extras.Role", related_name="validated_software", blank=True) inventory_items = models.ManyToManyField(to="dcim.InventoryItem", related_name="validated_software", blank=True) object_tags = models.ManyToManyField(to="extras.Tag", related_name="validated_software", blank=True) @@ -315,6 +316,7 @@ class Meta: clone_fields = ( "software", "devices", + "device_tenants", "device_types", "device_roles", "inventory_items", diff --git a/nautobot_device_lifecycle_mgmt/software_filters.py b/nautobot_device_lifecycle_mgmt/software_filters.py index e284a5e4..3d311531 100644 --- a/nautobot_device_lifecycle_mgmt/software_filters.py +++ b/nautobot_device_lifecycle_mgmt/software_filters.py @@ -53,14 +53,31 @@ def __init__(self, qs, item_obj): # pylint: disable=invalid-name def filter_qs(self): """Returns filtered ValidatedSoftwareLCM query set.""" - self.validated_software_qs = self.validated_software_qs.filter( - Q(devices=self.item_obj.pk) - | Q(device_types=self.item_obj.device_type.pk, device_roles=self.item_obj.role.pk) - | Q(device_types=self.item_obj.device_type.pk, device_roles=None) - | Q(device_types=None, device_roles=self.item_obj.role.pk) - | Q(object_tags__in=self.item_obj.tags.all()) - ).distinct() - + # 1. tenant relationship exists. + if self.item_obj.tenant: + self.validated_software_qs = self.validated_software_qs.filter( + Q(devices__in=[self.item_obj.pk]) + | Q(device_tenants=self.item_obj.tenant, device_types=self.item_obj.device_type.pk) + | Q(device_tenants=self.item_obj.tenant, device_roles=self.item_obj.role.pk) + | Q(device_tenants=self.item_obj.tenant, device_types=None) + | Q(object_tags__in=self.item_obj.tags.all()) + ).distinct() + # 2. No tenant relationship exists, filter based on device type, role, and tags. + else: + self.validated_software_qs = self.validated_software_qs.filter( + Q(devices__in=[self.item_obj.pk]) + | Q( + device_types=self.item_obj.device_type.pk, + device_roles=self.item_obj.role.pk, + device_tenants__isnull=True, + ) + | Q(device_types=self.item_obj.device_type.pk, device_roles=None, device_tenants__isnull=True) + | Q(device_types=None, device_roles=self.item_obj.role.pk, device_tenants__isnull=True) + | Q(object_tags__in=self.item_obj.tags.all()) + ).distinct() + # 3. Override qs when direct device assignments exist so no duplicates are returned. + if self.item_obj.validated_software.exists(): + self.validated_software_qs = self.validated_software_qs.filter(devices__in=[self.item_obj.pk]).distinct() self.validated_software_qs = self._add_weights().order_by("weight", "start") return self.validated_software_qs @@ -87,6 +104,30 @@ def _add_weights(self): When(device_types=self.item_obj.device_type.pk, device_roles=None, preferred=False, then=Value(1030)), When(device_roles=self.item_obj.role.pk, preferred=True, then=Value(40)), When(device_roles=self.item_obj.role.pk, preferred=False, then=Value(1040)), + When( + device_tenants=self.item_obj.tenant, + device_types=self.item_obj.device_type.pk, + preferred=True, + then=Value(50), + ), + When( + device_tenants=self.item_obj.tenant, + device_types=self.item_obj.device_type.pk, + preferred=False, + then=Value(1050), + ), + When( + device_tenants=self.item_obj.tenant, + device_roles=self.item_obj.role.pk, + preferred=True, + then=Value(60), + ), + When( + device_tenants=self.item_obj.tenant, + device_roles=self.item_obj.role.pk, + preferred=False, + then=Value(1060), + ), When(preferred=True, then=Value(990)), default=Value(1990), output_field=IntegerField(), diff --git a/nautobot_device_lifecycle_mgmt/tables.py b/nautobot_device_lifecycle_mgmt/tables.py index b4ea1df7..a78f2163 100644 --- a/nautobot_device_lifecycle_mgmt/tables.py +++ b/nautobot_device_lifecycle_mgmt/tables.py @@ -99,6 +99,7 @@ class ValidatedSoftwareLCMTable(BaseTable): ) valid = BooleanColumn(verbose_name="Valid Now", orderable=False) software = tables.LinkColumn(verbose_name="Software") + device_tenants = tables.ManyToManyColumn(verbose_name="Tenant") actions = ButtonsColumn(ValidatedSoftwareLCM, buttons=("edit", "delete")) preferred = BooleanColumn() @@ -110,6 +111,7 @@ class Meta(BaseTable.Meta): "pk", "name", "software", + "device_tenants", "start", "end", "valid", diff --git a/nautobot_device_lifecycle_mgmt/tests/test_filters.py b/nautobot_device_lifecycle_mgmt/tests/test_filters.py index da9ae1c9..ba93b3b4 100644 --- a/nautobot_device_lifecycle_mgmt/tests/test_filters.py +++ b/nautobot_device_lifecycle_mgmt/tests/test_filters.py @@ -9,6 +9,7 @@ from nautobot.apps.testing import FilterTestCases from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer, Platform, SoftwareVersion from nautobot.extras.models import Role, Status +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt.choices import ContractTypeChoices, CurrencyChoices, CVESeverityChoices from nautobot_device_lifecycle_mgmt.filters import ( @@ -78,6 +79,8 @@ def setUpTestData(cls): # pylint: disable=invalid-name cls.location4, _ = Location.objects.get_or_create( name="Location4", location_type=location_type_location_a, status=active_status ) + cls.tenant1, _ = Tenant.objects.get_or_create(name="Tenant A") + cls.tenant2, _ = Tenant.objects.get_or_create(name="Tenant B") cls.device_1, _ = Device.objects.get_or_create( name="r1", device_type=cls.device_type_1, @@ -94,6 +97,7 @@ def setUpTestData(cls): # pylint: disable=invalid-name ) cls.device_3, _ = Device.objects.get_or_create( name="r3", + tenant=cls.tenant1, device_type=cls.device_type_3, role=cls.devicerole_3, location=cls.location3, @@ -101,6 +105,7 @@ def setUpTestData(cls): # pylint: disable=invalid-name ) cls.device_4, _ = Device.objects.get_or_create( name="r4", + tenant=cls.tenant2, device_type=cls.device_type_4, role=cls.devicerole_4, location=cls.location4, @@ -638,6 +643,8 @@ def setUpTestData(cls): ) validated_software.save() validated_software.device_roles.set([device_role_router.pk]) + tenant_1, _ = Tenant.objects.get_or_create(name="Tenant A") + validated_software.device_tenants.set([tenant_1.pk]) def test_q_one_start(self): """Test q filter to find single record based on start date.""" @@ -654,6 +661,11 @@ def test_device_roles_name(self): params = {"device_roles": ["router"]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_device_tenants(self): + """Test device_tenants filter.""" + params = {"device_tenants": ["Tenant A"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_software(self): """Test software filter.""" params = {"software": [self.softwares[0].pk]} @@ -749,6 +761,9 @@ def setUpTestData(cls): release_date="2019-01-10", status=active_status, ) + cls.tenant, _ = Tenant.objects.get_or_create(name="Tenant A") + cls.device_1.tenant = cls.tenant + cls.device_1.save() DeviceSoftwareValidationResult.objects.create( device=cls.device_1, @@ -828,6 +843,16 @@ def test_location(self): params = {"location": [self.location.name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): + """Test tenant filter.""" + params = {"tenant": [self.tenant.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_tenant_id(self): + """Test tenant_id filter.""" + params = {"tenant_id": [self.tenant.id]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class InventoryItemSoftwareValidationResultFilterSetTestCase(FilterTestCases.FilterTestCase): """Tests for the DeviceSoftwareValidationResult model.""" @@ -946,6 +971,8 @@ class DeviceHardwareNoticeResultFilterSetTestCase(CommonTestDataMixin, FilterTes ("device_type_id", "device__device_type__id"), ("device_role", "device__role__name"), ("device_role_id", "device__role__id"), + ("tenant", "device__tenant__name"), + ("tenant_id", "device__tenant__id"), ] @skip("DeviceHardwareNoticeResultFilterSet q filter not implemented") diff --git a/nautobot_device_lifecycle_mgmt/tests/test_forms.py b/nautobot_device_lifecycle_mgmt/tests/test_forms.py index 0af3e8a2..27491829 100644 --- a/nautobot_device_lifecycle_mgmt/tests/test_forms.py +++ b/nautobot_device_lifecycle_mgmt/tests/test_forms.py @@ -13,6 +13,7 @@ SoftwareVersion, ) from nautobot.extras.models import Role, Status +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt.forms import ( ContractLCMForm, @@ -208,6 +209,7 @@ def setUp(self): device_type=self.devicetype_1, role=devicerole, name="Device 1", location=location1, status=active_status ) self.inventoryitem_1 = InventoryItem.objects.create(device=self.device_1, name="SwitchModule1") + self.tenant_1, _ = Tenant.objects.get_or_create(name="Tenant A") def test_specifying_all_fields_w_devices(self): data = { @@ -245,6 +247,39 @@ def test_specifying_all_fields_w_inventory_item_type(self): self.assertTrue(form.is_valid()) self.assertTrue(form.save()) + def test_specifying_all_fields_w_tenant(self): + """Test the form with the new device_tenants M2M field.""" + data = { + "software": self.software, + "device_tenants": [self.tenant_1.pk], # M2M fields expect a list of PKs + "start": "2021-06-06", + "end": "2023-08-31", + "preferred": False, + } + form = self.form_class(data) + + self.assertTrue(form.is_valid(), f"Form errors: {form.errors.as_json()}") + saved_obj = form.save() + + # Verify the M2M relationship was actually saved + self.assertIn(self.tenant_1, saved_obj.device_tenants.all()) + + def test_tenant_and_device_type_combination(self): + """Verify the form handles multiple assignment types simultaneously.""" + data = { + "software": self.software, + "device_tenants": [self.tenant_1.pk], + "device_types": [self.devicetype_1.pk], + "start": "2021-06-06", + "preferred": True, + } + form = self.form_class(data) + self.assertTrue(form.is_valid()) + saved_obj = form.save() + + self.assertEqual(saved_obj.device_tenants.count(), 1) + self.assertEqual(saved_obj.device_types.count(), 1) + def test_software_missing(self): data = { "end": "2023-08-31", diff --git a/nautobot_device_lifecycle_mgmt/tests/test_model.py b/nautobot_device_lifecycle_mgmt/tests/test_model.py index 62ede9ab..45c199bb 100644 --- a/nautobot_device_lifecycle_mgmt/tests/test_model.py +++ b/nautobot_device_lifecycle_mgmt/tests/test_model.py @@ -9,6 +9,7 @@ from nautobot.apps.testing import TestCase from nautobot.dcim.models import DeviceType, Manufacturer, Platform, SoftwareVersion from nautobot.extras.models import Status +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt.choices import ReportRunTypeChoices from nautobot_device_lifecycle_mgmt.models import ( @@ -145,6 +146,8 @@ def setUpTestData(cls): cls.content_type_devicetype = ContentType.objects.get(app_label="dcim", model="devicetype") cls.inventoryitem_1, cls.inventoryitem_2 = create_inventory_items()[:2] cls.device_1, cls.device_2 = cls.inventoryitem_1.device, cls.inventoryitem_2.device + cls.tenant_1, _ = Tenant.objects.get_or_create(name="Tenant A") + cls.tenant_2, _ = Tenant.objects.get_or_create(name="Tenant B") def test_create_validatedsoftwarelcm_required_only(self): """Successfully create ValidatedSoftwareLCM with required fields only.""" @@ -277,6 +280,90 @@ def test_get_for_object_inventoryitem(self): self.assertEqual(validated_software_for_inventoryitem.count(), 1) self.assertTrue(self.inventoryitem_1 in validated_software_for_inventoryitem.first().inventory_items.all()) + def test_create_validatedsoftwarelcm_w_tenant(self): + """Successfully create ValidatedSoftwareLCM with a tenant assigned.""" + validatedsoftwarelcm = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2023, 1, 1), + ) + validatedsoftwarelcm.device_tenants.set([self.tenant_1]) + + self.assertIn(self.tenant_1, validatedsoftwarelcm.device_tenants.all()) + self.assertEqual(validatedsoftwarelcm.device_tenants.count(), 1) + + def test_get_for_object_device_with_tenant(self): + """ + Verify software matching the device's tenant is retrieved, + while software for other tenants is excluded. + """ + self.device_1.tenant = self.tenant_1 + self.device_1.device_type = self.device_type_1 + self.device_1.save() + + lcm_tenant_a = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2013, 11, 22), + ) + lcm_tenant_a.device_tenants.set([self.tenant_1]) + lcm_tenant_a.device_types.set([self.device_type_1]) + + lcm_tenant_b = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2023, 1, 1), + ) + lcm_tenant_b.device_tenants.set([self.tenant_2]) + lcm_tenant_b.device_types.set([self.device_type_1]) + + qs = ValidatedSoftwareLCM.objects.get_for_object(self.device_1) + + self.assertIn(lcm_tenant_a, qs) + self.assertNotIn(lcm_tenant_b, qs) + + def test_get_for_object_device_tenant_no_fallback_to_global(self): + """ + Verify that a device with a tenant cannot see "Global" software + (where device_tenants is empty). + """ + self.device_1.tenant = self.tenant_1 + self.device_1.device_type = self.device_type_1 + self.device_1.save() + + lcm_global = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2013, 11, 2), + ) + + qs = ValidatedSoftwareLCM.objects.get_for_object(self.device_1) + + self.assertNotIn(lcm_global, qs) + + def test_tenant_filtering_logic_on_device(self): + """ + Verify that a device belonging to a tenant retrieves the correct + software based on the tenant-specific filtering logic. + """ + self.device_1.tenant = self.tenant_1 + self.device_1.device_type = self.device_type_1 + self.device_1.save() + + tenant_software = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2013, 11, 8), + ) + tenant_software.device_tenants.set([self.tenant_1]) + tenant_software.device_types.set([self.device_type_1]) + + other_tenant_software = ValidatedSoftwareLCM.objects.create( + software=self.software, + start=date(2013, 11, 9), + ) + other_tenant_software.device_tenants.set([self.tenant_2]) + + validated_qs = ValidatedSoftwareLCM.objects.get_for_object(self.device_1) + + self.assertIn(tenant_software, validated_qs) + self.assertNotIn(other_tenant_software, validated_qs) + class DeviceSoftwareValidationResultTestCase(TestCase): # pylint: disable=too-many-instance-attributes """Tests for the DeviceSoftwareValidationResult model.""" diff --git a/nautobot_device_lifecycle_mgmt/views.py b/nautobot_device_lifecycle_mgmt/views.py index 9ef27b2b..10c85f07 100644 --- a/nautobot_device_lifecycle_mgmt/views.py +++ b/nautobot_device_lifecycle_mgmt/views.py @@ -48,6 +48,7 @@ from nautobot.core.views import generic from nautobot.dcim.models import Device, DeviceType, InventoryItem, SoftwareVersion from nautobot.extras.models import Role, Tag +from nautobot.tenancy.models import Tenant from nautobot_device_lifecycle_mgmt import choices, filters, forms, helpers, models, tables from nautobot_device_lifecycle_mgmt.api import serializers @@ -163,6 +164,7 @@ class ValidatedSoftwareLCMUIViewSet(NautobotUIViewSet): related_models=[ (Device, "validated_software__in"), (DeviceType, "validated_software__in"), + (Tenant, "validated_software_tenants__in"), (Role, "validated_software__in"), (InventoryItem, "validated_software__in"), (Tag, "validated_software__in"),