feat: add tenancy support for software licenses

Add tenant group and tenant assignments across the model, forms, filters, API, tables, imports, and tests. Replace the Docker-based setup with Git/Pip installation documentation for NetBox 4.6.5.
This commit is contained in:
2026-07-24 15:22:08 +02:00
parent 8a2dabc382
commit 01d40ddecc
26 changed files with 257 additions and 422 deletions
+12
View File
@@ -1,3 +1,4 @@
from rest_framework import serializers
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
from netbox.api.serializers import NetBoxModelSerializer
@@ -27,6 +28,8 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
"comments",
"custom_field_data",
@@ -38,6 +41,15 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
def get_display(self, obj):
return f"{obj}"
def validate(self, attrs):
tenant_group = attrs.get("tenant_group", getattr(self.instance, "tenant_group", None))
tenant = attrs.get("tenant", getattr(self.instance, "tenant", None))
if tenant_group and tenant and tenant.group_id != tenant_group.pk:
raise serializers.ValidationError(
{"tenant": "The selected tenant does not belong to the selected tenant group."}
)
return attrs
class SoftwareProductSerializer(NetBoxModelSerializer):
display = SerializerMethodField()
+5
View File
@@ -11,6 +11,7 @@ from netbox_slm.models import (
SoftwareReleaseTypes,
)
from virtualization.models import Cluster, VirtualMachine
from tenancy.models import Tenant, TenantGroup
class SoftwareProductFilterSet(NetBoxModelFilterSet):
@@ -122,6 +123,8 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
)
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
installation_id = ModelMultipleChoiceFilter(queryset=SoftwareProductInstallation.objects.all())
tenant_group_id = ModelMultipleChoiceFilter(queryset=TenantGroup.objects.all(), label="Tenant Group")
tenant_id = ModelMultipleChoiceFilter(queryset=Tenant.objects.all(), label="Tenant")
class Meta:
model = SoftwareLicense
@@ -139,6 +142,8 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
| Q(installation__device__name__icontains=value)
| Q(installation__virtualmachine__name__icontains=value)
| Q(installation__cluster__name__icontains=value)
| Q(tenant_group__name__icontains=value)
| Q(tenant__name__icontains=value)
| Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
+44
View File
@@ -9,6 +9,7 @@ from netbox_slm.models import (
SoftwareLicense,
spdx_license_names,
)
from tenancy.models import Tenant, TenantGroup
from utilities.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import (
CommentField,
@@ -48,6 +49,17 @@ class SoftwareLicenseForm(NetBoxModelForm):
),
query_params=dict(software_product="$software_product"),
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label="Tenant",
)
class Meta:
model = SoftwareLicense
@@ -65,6 +77,8 @@ class SoftwareLicenseForm(NetBoxModelForm):
"license_amount",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
"comments",
)
@@ -84,6 +98,8 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
"software_product_id",
"version_id",
"installation_id",
"tenant_group_id",
"tenant_id",
),
)
selector_fields = ("q", "filter_id", "name")
@@ -112,6 +128,17 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
required=False,
label="Installation",
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group_id"},
label="Tenant",
)
class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
@@ -132,6 +159,8 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
"license_amount",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
)
@@ -151,6 +180,8 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
),
)
nullable_fields = (
@@ -164,6 +195,8 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"license_amount",
"version",
"installation",
"tenant_group",
"tenant",
)
tag = TagFilterField(model)
@@ -197,3 +230,14 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
),
query_params=dict(software_product="$software_product"),
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label="Tenant",
)
@@ -0,0 +1,34 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0001_initial"),
("netbox_slm", "0010_softwarelicense_spdx_expression"),
]
operations = [
migrations.AddField(
model_name="softwarelicense",
name="tenant_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="software_licenses",
to="tenancy.tenantgroup",
),
),
migrations.AddField(
model_name="softwarelicense",
name="tenant",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="software_licenses",
to="tenancy.tenant",
),
),
]
+19
View File
@@ -163,6 +163,20 @@ class SoftwareLicense(NetBoxModel):
installation = models.ForeignKey(
to="netbox_slm.SoftwareProductInstallation", on_delete=models.SET_NULL, null=True, blank=True
)
tenant_group = models.ForeignKey(
to="tenancy.TenantGroup",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="software_licenses",
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="software_licenses",
)
objects = RestrictedQuerySet.as_manager()
@@ -172,6 +186,11 @@ class SoftwareLicense(NetBoxModel):
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwarelicense", kwargs={"pk": self.pk})
def clean(self):
super().clean()
if self.tenant_group and self.tenant and self.tenant.group_id != self.tenant_group_id:
raise ValidationError({"tenant": "The selected tenant does not belong to the selected tenant group."})
@property
def stored_location_txt(self):
if self.stored_location_url and not self.stored_location:
+6
View File
@@ -144,6 +144,8 @@ class SoftwareLicenseTable(NetBoxTable):
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
version = tables.Column(accessor="version", linkify=True)
installation = tables.Column(accessor="installation", linkify=True)
tenant_group = tables.Column(accessor="tenant_group", verbose_name="Tenant Group", linkify=True)
tenant = tables.Column(accessor="tenant", linkify=True)
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list")
@@ -162,6 +164,8 @@ class SoftwareLicenseTable(NetBoxTable):
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
"support",
"license_amount",
"tags",
@@ -173,6 +177,8 @@ class SoftwareLicenseTable(NetBoxTable):
"manufacturer",
"software_product",
"installation",
"tenant_group",
"tenant",
"expiration_date",
"tags",
)
@@ -40,6 +40,14 @@
<th scope="row">Installation</th>
<td>{{ object.installation|linkify }}</td>
</tr>
<tr>
<th scope="row">Tenant Group</th>
<td>{{ object.tenant_group|linkify }}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<td>{{ object.tenant|linkify }}</td>
</tr>
<tr>
<th scope="row">Stored location</th>
{% if object.stored_location_url %}
+7
View File
@@ -2,6 +2,7 @@ from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterType, VirtualMachine
@@ -26,6 +27,8 @@ class SlmBaseTestCase(TestCase):
cls.vm = VirtualMachine.objects.create(name="test VM")
cluster_type = ClusterType.objects.create(name="test cluster type")
cls.cluster = Cluster.objects.create(name="test cluster", type=cluster_type)
cls.tenant_group = TenantGroup.objects.create(name="test tenant group", slug="test-tenant-group")
cls.tenant = Tenant.objects.create(name="test tenant", slug="test-tenant", group=cls.tenant_group)
cls.software_product = SoftwareProduct.objects.create(name=cls.p_name)
cls.software_product_version = SoftwareProductVersion.objects.create(
@@ -40,6 +43,8 @@ class SlmBaseTestCase(TestCase):
version=cls.software_product_version,
installation=cls.software_product_installation,
stored_location_url=cls.test_url,
tenant_group=cls.tenant_group,
tenant=cls.tenant,
)
@classmethod
@@ -48,6 +53,8 @@ class SlmBaseTestCase(TestCase):
SoftwareProductInstallation.objects.all().delete()
SoftwareProductVersion.objects.all().delete()
SoftwareProduct.objects.all().delete()
Tenant.objects.all().delete()
TenantGroup.objects.all().delete()
Cluster.objects.all().delete()
ClusterType.objects.all().delete()
VirtualMachine.objects.all().delete()
+15
View File
@@ -1,3 +1,6 @@
from django.core.exceptions import ValidationError
from tenancy.models import Tenant, TenantGroup
from .base import SlmBaseTestCase
@@ -56,3 +59,15 @@ class ModelTestCase(SlmBaseTestCase):
self.software_license.stored_location = "GitHub"
self.software_license.save()
self.assertEqual("GitHub", self.software_license.stored_location_txt)
def test_software_license_tenancy(self):
self.assertEqual(self.tenant_group, self.software_license.tenant_group)
self.assertEqual(self.tenant, self.software_license.tenant)
def test_software_license_rejects_tenant_from_other_group(self):
other_group = TenantGroup.objects.create(name="other group", slug="other-group")
other_tenant = Tenant.objects.create(name="other tenant", slug="other-tenant", group=other_group)
self.software_license.tenant = other_tenant
with self.assertRaisesMessage(ValidationError, "does not belong to the selected tenant group"):
self.software_license.full_clean()