Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/573.added
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions docs/user/software_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions nautobot_device_lifecycle_mgmt/filter_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -217,5 +243,6 @@ class SoftwareVersionFilterExtension(FilterExtension): # pylint: disable=too-fe
RoleFilterExtension,
DeviceTypeFilterExtension,
TagFilterExtension,
TenantFilterExtension,
SoftwareVersionFilterExtension,
]
39 changes: 38 additions & 1 deletion nautobot_device_lifecycle_mgmt/filters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Filtering implementation for the Lifecycle Management app."""
# pylint: disable=too-many-lines

import datetime

Expand All @@ -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 (
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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},
Expand All @@ -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(),
Expand Down
39 changes: 38 additions & 1 deletion nautobot_device_lifecycle_mgmt/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Forms implementation for the Lifecycle Management app."""
# pylint: disable=too-many-lines

import logging

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -192,6 +195,7 @@ class Meta:
fields = [ # pylint: disable=E4271
"software",
"devices",
"device_tenants",
"device_types",
"device_roles",
"inventory_items",
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -285,6 +301,7 @@ class Meta:

nullable_fields = [
"devices",
"device_tenants",
"device_types",
"device_roles",
"inventory_items",
Expand All @@ -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",
Expand Down Expand Up @@ -338,6 +360,7 @@ class Meta:
"q",
"software",
"devices",
"device_tenants",
"device_types",
"device_roles",
"inventory_items",
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -422,6 +451,7 @@ class Meta:
"supported",
"platform",
"location",
"tenant",
"device",
"device_status",
"device_type",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -498,6 +534,7 @@ class Meta:
"software",
"valid",
"platform",
"tenant",
"location",
"device",
"device_type",
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
),
]
2 changes: 2 additions & 0 deletions nautobot_device_lifecycle_mgmt/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably just have related_name="validated_software"

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)
Expand All @@ -315,6 +316,7 @@ class Meta:
clone_fields = (
"software",
"devices",
"device_tenants",
"device_types",
"device_roles",
"inventory_items",
Expand Down
Loading
Loading