fix: clarify installation platform selection

Group device, virtual machine, and cluster as the required platform destination and validate that exactly one is selected with an actionable localized error.
This commit is contained in:
2026-07-27 12:28:26 +02:00
parent adfc2ae72c
commit 71a5db0ece
8 changed files with 71 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Fixed
* Make the required single platform destination explicit when creating an installation
### Added ### Added
* Optional tenant group and tenant assignments for software licenses * Optional tenant group and tenant assignments for software licenses
+1 -1
View File
@@ -63,7 +63,7 @@ Die installierte Version kann anschließend geprüft werden:
/opt/netbox/venv/bin/pip show netbox-slm /opt/netbox/venv/bin/pip show netbox-slm
``` ```
Für diese Variante muss dort mindestens Version `1.11.0` stehen. Für diese Variante muss dort mindestens Version `1.11.1` stehen.
### 3. Plugin in NetBox aktivieren ### 3. Plugin in NetBox aktivieren
+1 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
__version__ = "1.11.0" __version__ = "1.11.1"
class SLMConfig(PluginConfig): class SLMConfig(PluginConfig):
@@ -18,14 +18,28 @@ from virtualization.models import Cluster, VirtualMachine
class SoftwareProductInstallationForm(NetBoxModelForm): class SoftwareProductInstallationForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = (
FieldSet("device", "virtualmachine", "cluster", name=_("Platform Destination (select exactly one)")),
FieldSet("software_product", "version", name=_("Software")),
FieldSet("tags", name=_("Tags")),
)
device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False) device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
help_text=_("Select either a device, a virtual machine, or a cluster."),
)
virtualmachine = DynamicModelChoiceField( virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
label=_("Virtual Machine"), label=_("Virtual Machine"),
help_text=_("Leave empty when another platform destination is selected."),
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
help_text=_("Leave empty when another platform destination is selected."),
) )
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=True, required=True,
@@ -60,6 +74,17 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
) )
return version return version
def clean(self):
cleaned_data = super().clean()
destinations = [
cleaned_data.get("device"),
cleaned_data.get("virtualmachine"),
cleaned_data.get("cluster"),
]
if sum(destination is not None for destination in destinations) != 1:
raise ValidationError(_("Select exactly one platform destination: device, virtual machine, or cluster."))
return cleaned_data
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm): class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProductInstallation model = SoftwareProductInstallation
Binary file not shown.
@@ -321,3 +321,18 @@ msgstr "Erneuerungsintervall und Einheit müssen gemeinsam angegeben werden."
msgid "A lifetime license cannot have a renewal interval or expiration date." msgid "A lifetime license cannot have a renewal interval or expiration date."
msgstr "Eine Lifetime-Lizenz darf kein Erneuerungsintervall und kein Ablaufdatum haben." msgstr "Eine Lifetime-Lizenz darf kein Erneuerungsintervall und kein Ablaufdatum haben."
msgid "Platform Destination (select exactly one)"
msgstr "Plattformziel (genau eines auswählen)"
msgid "Software"
msgstr "Software"
msgid "Select either a device, a virtual machine, or a cluster."
msgstr "Wähle entweder ein Gerät, eine virtuelle Maschine oder einen Cluster aus."
msgid "Leave empty when another platform destination is selected."
msgstr "Leer lassen, wenn ein anderes Plattformziel ausgewählt wurde."
msgid "Select exactly one platform destination: device, virtual machine, or cluster."
msgstr "Wähle genau ein Plattformziel aus: Gerät, virtuelle Maschine oder Cluster."
+3 -1
View File
@@ -146,7 +146,9 @@ class SoftwareProductInstallation(NetBoxModel):
| models.Q(device__isnull=True, virtualmachine__isnull=False, cluster__isnull=True) | models.Q(device__isnull=True, virtualmachine__isnull=False, cluster__isnull=True)
| models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False) | models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False)
), ),
violation_error_message=_("Installation requires exactly one platform destination."), violation_error_message=_(
"Select exactly one platform destination: device, virtual machine, or cluster."
),
) )
] ]
+20 -1
View File
@@ -1,6 +1,6 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext, override from django.utils.translation import gettext, override
from netbox_slm.forms import SoftwareLicenseForm from netbox_slm.forms import SoftwareLicenseForm, SoftwareProductInstallationForm
from netbox_slm.models import RenewalIntervalUnits, SoftwareLicense from netbox_slm.models import RenewalIntervalUnits, SoftwareLicense
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@@ -56,6 +56,25 @@ class ModelTestCase(SlmBaseTestCase):
self.assertEqual("cluster", self.software_product_installation.render_type()) self.assertEqual("cluster", self.software_product_installation.render_type())
self.assertEqual(self.cluster, self.software_product_installation.platform) self.assertEqual(self.cluster, self.software_product_installation.platform)
def test_installation_form_requires_exactly_one_platform_destination(self):
base_data = {
"software_product": self.software_product.pk,
"version": self.software_product_version.pk,
}
no_destination_form = SoftwareProductInstallationForm(data=base_data)
self.assertFalse(no_destination_form.is_valid())
self.assertIn("Select exactly one platform destination", str(no_destination_form.non_field_errors()))
one_destination_form = SoftwareProductInstallationForm(data={**base_data, "device": self.device.pk})
self.assertTrue(one_destination_form.is_valid(), one_destination_form.errors)
multiple_destinations_form = SoftwareProductInstallationForm(
data={**base_data, "device": self.device.pk, "cluster": self.cluster.pk}
)
self.assertFalse(multiple_destinations_form.is_valid())
self.assertIn("Select exactly one platform destination", str(multiple_destinations_form.non_field_errors()))
def test_software_license_stored_location_txt(self): def test_software_license_stored_location_txt(self):
self.assertEqual("Link", self.software_license.stored_location_txt) self.assertEqual("Link", self.software_license.stored_location_txt)