diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bbfef..5f74964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * Optional tenant group and tenant assignments for software licenses * German translation for navigation, forms, tables, detail views, choices, and validation messages +* Optional lifetime or recurring renewal interval for licenses +* Optional license file, license key, and provider portal URL ### Changed @@ -13,6 +15,7 @@ * Replace the Docker-based setup with Git/Pip installation documentation * Render tenant group and tenant in a dedicated license form section * Bump the package version so Git/Pip upgrades replace older installations +* Make the license type field optional ## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25 diff --git a/README.md b/README.md index a3c838e..f5a672f 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,16 @@ Benutzer ausgewählten Sprache. - Versionen, Release-Typen und Supportzeiträume erfassen - Installationen Geräten, virtuellen Maschinen oder Clustern zuordnen - Softwarelizenzen einschließlich Laufzeit, Umfang und Ablageort verwalten +- Lifetime-Lizenzen oder Erneuerungsintervalle in Tagen, Monaten oder Jahren erfassen +- Lizenzdateien, Lizenzschlüssel und Links zum Anbieterportal hinterlegen - Lizenzen Mandantengruppen und Mandanten zuordnen - Bedienung über die NetBox-Oberfläche und REST-API - Filter, Bulk-Import und Bulk-Bearbeitung +> **Hinweis:** Lizenzschlüssel werden in der NetBox-Datenbank und Lizenzdateien im konfigurierten NetBox- +> Medienverzeichnis gespeichert. Der Zugriff auf Datenbank, REST-API und Medienverzeichnis sollte entsprechend +> geschützt und gesichert werden. + ## Installation NetBox installiert lokale Erweiterungen aus `/opt/netbox/local_requirements.txt`. Die Git-URL muss dort dauerhaft @@ -57,7 +63,7 @@ Die installierte Version kann anschließend geprüft werden: /opt/netbox/venv/bin/pip show netbox-slm ``` -Für diese Variante muss dort mindestens Version `1.10.0` stehen. +Für diese Variante muss dort mindestens Version `1.11.0` stehen. ### 3. Plugin in NetBox aktivieren diff --git a/netbox_slm/__init__.py b/netbox_slm/__init__.py index 9f6a914..6a5dcc2 100644 --- a/netbox_slm/__init__.py +++ b/netbox_slm/__init__.py @@ -17,7 +17,7 @@ limitations under the License. from netbox.plugins import PluginConfig from django.utils.translation import gettext_lazy as _ -__version__ = "1.10.0" +__version__ = "1.11.0" class SLMConfig(PluginConfig): diff --git a/netbox_slm/api/serializers.py b/netbox_slm/api/serializers.py index 393ad69..e6e88d9 100644 --- a/netbox_slm/api/serializers.py +++ b/netbox_slm/api/serializers.py @@ -24,8 +24,14 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer): "stored_location_url", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", + "license_file", + "license_key", + "provider_portal_url", "software_product", "version", "installation", @@ -49,6 +55,20 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer): raise serializers.ValidationError( {"tenant": _("The selected tenant does not belong to the selected tenant group.")} ) + lifetime = attrs.get("lifetime", getattr(self.instance, "lifetime", False)) + renewal_interval = attrs.get("renewal_interval", getattr(self.instance, "renewal_interval", None)) + renewal_interval_unit = attrs.get( + "renewal_interval_unit", getattr(self.instance, "renewal_interval_unit", "") + ) + expiration_date = attrs.get("expiration_date", getattr(self.instance, "expiration_date", None)) + if bool(renewal_interval) != bool(renewal_interval_unit): + raise serializers.ValidationError( + {"renewal_interval": _("Renewal interval and unit must be specified together.")} + ) + if lifetime and (renewal_interval or renewal_interval_unit or expiration_date): + raise serializers.ValidationError( + {"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")} + ) return attrs diff --git a/netbox_slm/filtersets.py b/netbox_slm/filtersets.py index 3fdd089..ea9a8f3 100644 --- a/netbox_slm/filtersets.py +++ b/netbox_slm/filtersets.py @@ -1,6 +1,6 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter +from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter from dcim.models import Device, Manufacturer from netbox.filtersets import NetBoxModelFilterSet @@ -10,6 +10,7 @@ from netbox_slm.models import ( SoftwareProductInstallation, SoftwareLicense, SoftwareReleaseTypes, + RenewalIntervalUnits, ) from virtualization.models import Cluster, VirtualMachine from tenancy.models import Tenant, TenantGroup @@ -117,6 +118,8 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet): type = CharFilter(lookup_expr="icontains") spdx_expression = CharFilter(lookup_expr="icontains", label=_("SPDX expression")) stored_location = CharFilter(lookup_expr="icontains") + renewal_interval = NumberFilter() + renewal_interval_unit = MultipleChoiceFilter(choices=RenewalIntervalUnits.choices) software_product_id = ModelMultipleChoiceFilter( queryset=SoftwareProduct.objects.all(), @@ -129,7 +132,7 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet): class Meta: model = SoftwareLicense - fields = ("support",) + fields = ("support", "lifetime") def search(self, queryset, name, value): """Perform the filtered search.""" diff --git a/netbox_slm/forms/software_license.py b/netbox_slm/forms/software_license.py index fd48d67..e129473 100644 --- a/netbox_slm/forms/software_license.py +++ b/netbox_slm/forms/software_license.py @@ -1,4 +1,4 @@ -from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField +from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField, Textarea from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -8,6 +8,7 @@ from netbox_slm.models import ( SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense, + RenewalIntervalUnits, spdx_license_names, ) from tenancy.models import Tenant, TenantGroup @@ -42,10 +43,14 @@ class SoftwareLicenseForm(NetBoxModelForm): "stored_location_url", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", name=_("License Details"), ), + FieldSet("license_file", "license_key", "provider_portal_url", name=_("License Credentials")), FieldSet("tags", name=_("Tags")), ) @@ -53,6 +58,9 @@ class SoftwareLicenseForm(NetBoxModelForm): stored_location_url = LaxURLField(required=False) start_date = DateField(required=False, widget=DatePicker()) expiration_date = DateField(required=False, widget=DatePicker()) + renewal_interval = IntegerField(required=False, min_value=1) + license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3})) + provider_portal_url = LaxURLField(required=False) software_product = DynamicModelChoiceField( queryset=SoftwareProduct.objects.all(), @@ -97,8 +105,14 @@ class SoftwareLicenseForm(NetBoxModelForm): "stored_location_url", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", + "license_file", + "license_key", + "provider_portal_url", "version", "installation", "tenant_group", @@ -119,6 +133,9 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm): "spdx_expression", "stored_location", "support", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "software_product_id", "version_id", "installation_id", @@ -136,6 +153,9 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm): spdx_expression = CharField(required=False, label=_("SPDX expression")) stored_location = CharField(required=False) support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES) + lifetime = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES) + renewal_interval = IntegerField(required=False, min_value=1) + renewal_interval_unit = ChoiceField(required=False, choices=RenewalIntervalUnits.choices) software_product_id = DynamicModelMultipleChoiceField( queryset=SoftwareProduct.objects.all(), @@ -181,8 +201,13 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm): "stored_location", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", + "license_key", + "provider_portal_url", "version", "installation", "tenant_group", @@ -201,8 +226,13 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm): "stored_location_url", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", + "license_key", + "provider_portal_url", "software_product", "version", "installation", @@ -217,8 +247,12 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm): "stored_location_url", "start_date", "expiration_date", + "renewal_interval", + "renewal_interval_unit", "support", "license_amount", + "license_key", + "provider_portal_url", "version", "installation", "tenant_group", @@ -234,8 +268,13 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm): stored_location_url = LaxURLField(required=False) start_date = DateField(required=False, widget=DatePicker()) expiration_date = DateField(required=False, widget=DatePicker()) + lifetime = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES) + renewal_interval = IntegerField(required=False, min_value=1) + renewal_interval_unit = ChoiceField(required=False, choices=RenewalIntervalUnits.choices) support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES) license_amount = IntegerField(required=False, min_value=0) + license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3})) + provider_portal_url = LaxURLField(required=False) software_product = DynamicModelChoiceField( queryset=SoftwareProduct.objects.all(), diff --git a/netbox_slm/locale/de/LC_MESSAGES/django.mo b/netbox_slm/locale/de/LC_MESSAGES/django.mo index 8303efd..8dc7426 100644 Binary files a/netbox_slm/locale/de/LC_MESSAGES/django.mo and b/netbox_slm/locale/de/LC_MESSAGES/django.mo differ diff --git a/netbox_slm/locale/de/LC_MESSAGES/django.po b/netbox_slm/locale/de/LC_MESSAGES/django.po index 7207611..3382503 100644 --- a/netbox_slm/locale/de/LC_MESSAGES/django.po +++ b/netbox_slm/locale/de/LC_MESSAGES/django.po @@ -261,3 +261,63 @@ msgstr "Softwarelizenz" msgid "software licenses" msgstr "Softwarelizenzen" + +msgid "Days" +msgstr "Tage" + +msgid "Months" +msgstr "Monate" + +msgid "Years" +msgstr "Jahre" + +msgid "lifetime license" +msgstr "Lifetime-Lizenz" + +msgid "renewal interval" +msgstr "Erneuerungsintervall" + +msgid "renewal interval unit" +msgstr "Einheit des Erneuerungsintervalls" + +msgid "license file" +msgstr "Lizenzdatei" + +msgid "license key" +msgstr "Lizenzschlüssel" + +msgid "provider portal URL" +msgstr "Anbieterportal-URL" + +msgid "License Credentials" +msgstr "Lizenzdaten" + +msgid "Lifetime license" +msgstr "Lifetime-Lizenz" + +msgid "Renewal interval" +msgstr "Erneuerungsintervall" + +msgid "License file" +msgstr "Lizenzdatei" + +msgid "Download license file" +msgstr "Lizenzdatei herunterladen" + +msgid "License key" +msgstr "Lizenzschlüssel" + +msgid "Provider Portal" +msgstr "Anbieterportal" + +msgid "Yes" +msgstr "Ja" + +msgid "No" +msgstr "Nein" + +msgid "Renewal interval and unit must be specified together." +msgstr "Erneuerungsintervall und Einheit müssen gemeinsam angegeben werden." + +msgid "A lifetime license cannot have a renewal interval or expiration date." +msgstr "Eine Lifetime-Lizenz darf kein Erneuerungsintervall und kein Ablaufdatum haben." diff --git a/netbox_slm/migrations/0012_softwarelicense_renewal_and_credentials.py b/netbox_slm/migrations/0012_softwarelicense_renewal_and_credentials.py new file mode 100644 index 0000000..5eff2ec --- /dev/null +++ b/netbox_slm/migrations/0012_softwarelicense_renewal_and_credentials.py @@ -0,0 +1,51 @@ +from django.db import migrations, models +import django.core.validators +import netbox_slm.models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_slm", "0011_softwarelicense_tenancy"), + ] + + operations = [ + migrations.AlterField( + model_name="softwarelicense", + name="type", + field=models.CharField(blank=True, max_length=128), + ), + migrations.AddField( + model_name="softwarelicense", + name="lifetime", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="softwarelicense", + name="renewal_interval", + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name="softwarelicense", + name="renewal_interval_unit", + field=models.CharField( + blank=True, + choices=[("days", "Days"), ("months", "Months"), ("years", "Years")], + max_length=8, + ), + ), + migrations.AddField( + model_name="softwarelicense", + name="license_file", + field=models.FileField(blank=True, null=True, upload_to="netbox_slm/licenses/"), + ), + migrations.AddField( + model_name="softwarelicense", + name="license_key", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="softwarelicense", + name="provider_portal_url", + field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True), + ), + ] diff --git a/netbox_slm/models.py b/netbox_slm/models.py index d6ebb52..4dbd851 100644 --- a/netbox_slm/models.py +++ b/netbox_slm/models.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse from django.utils.html import format_html @@ -57,6 +58,12 @@ class SoftwareReleaseTypes(models.TextChoices): STABLE = "S", _("Stable release") +class RenewalIntervalUnits(models.TextChoices): + DAYS = "days", _("Days") + MONTHS = "months", _("Months") + YEARS = "years", _("Years") + + class SoftwareProductVersion(NetBoxModel): name = models.CharField(_("name"), max_length=64) comments = models.TextField(_("comments"), blank=True) @@ -176,7 +183,7 @@ class SoftwareLicense(NetBoxModel): comments = models.TextField(_("comments"), blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True) - type = models.CharField(_("type"), max_length=128) + type = models.CharField(_("type"), max_length=128, blank=True) spdx_expression = models.CharField( _("SPDX expression"), max_length=64, null=True, blank=True, validators=[validate_spdx_expression] ) @@ -186,6 +193,16 @@ class SoftwareLicense(NetBoxModel): expiration_date = models.DateField(_("expiration date"), null=True, blank=True) support = models.BooleanField(_("support"), default=None, null=True, blank=True) license_amount = models.PositiveIntegerField(_("license amount"), default=None, null=True, blank=True) + lifetime = models.BooleanField(_("lifetime license"), default=False) + renewal_interval = models.PositiveIntegerField( + _("renewal interval"), null=True, blank=True, validators=[MinValueValidator(1)] + ) + renewal_interval_unit = models.CharField( + _("renewal interval unit"), max_length=8, choices=RenewalIntervalUnits.choices, blank=True + ) + license_file = models.FileField(_("license file"), upload_to="netbox_slm/licenses/", null=True, blank=True) + license_key = models.TextField(_("license key"), blank=True) + provider_portal_url = LaxURLField(_("provider portal URL"), max_length=1024, null=True, blank=True) software_product = models.ForeignKey( to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT @@ -239,6 +256,14 @@ class SoftwareLicense(NetBoxModel): raise ValidationError( {"tenant": _("The selected tenant does not belong to the selected tenant group.")} ) + if bool(self.renewal_interval) != bool(self.renewal_interval_unit): + raise ValidationError( + {"renewal_interval": _("Renewal interval and unit must be specified together.")} + ) + if self.lifetime and (self.renewal_interval or self.renewal_interval_unit or self.expiration_date): + raise ValidationError( + {"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")} + ) @property def stored_location_txt(self): diff --git a/netbox_slm/tables.py b/netbox_slm/tables.py index 0bd5667..0048946 100644 --- a/netbox_slm/tables.py +++ b/netbox_slm/tables.py @@ -147,6 +147,7 @@ class SoftwareLicenseTable(NetBoxTable): 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) + provider_portal_url = tables.Column(verbose_name=_("Provider Portal"), linkify=True) tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list") @@ -161,6 +162,9 @@ class SoftwareLicenseTable(NetBoxTable): "stored_location", "start_date", "expiration_date", + "lifetime", + "renewal_interval", + "renewal_interval_unit", "manufacturer", "software_product", "version", @@ -169,6 +173,7 @@ class SoftwareLicenseTable(NetBoxTable): "tenant", "support", "license_amount", + "provider_portal_url", "tags", ) default_columns = ( @@ -181,6 +186,7 @@ class SoftwareLicenseTable(NetBoxTable): "tenant_group", "tenant", "expiration_date", + "lifetime", "tags", ) diff --git a/netbox_slm/templates/netbox_slm/softwarelicense.html b/netbox_slm/templates/netbox_slm/softwarelicense.html index 12093fe..b4340d3 100644 --- a/netbox_slm/templates/netbox_slm/softwarelicense.html +++ b/netbox_slm/templates/netbox_slm/softwarelicense.html @@ -65,6 +65,20 @@ {% trans "Expiration date" %} {{ object.expiration_date }} + + {% trans "Lifetime license" %} + {% if object.lifetime %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} + + + {% trans "Renewal interval" %} + +{% if object.renewal_interval %} + {{ object.renewal_interval }} {{ object.get_renewal_interval_unit_display }} +{% else %} + {{ None|placeholder }} +{% endif %} + + {% trans "Support" %} {{ object.support }} @@ -73,6 +87,30 @@ {% trans "License amount" %} {{ object.license_amount }} + + {% trans "License file" %} + +{% if object.license_file %} + {% trans "Download license file" %} +{% else %} + {{ None|placeholder }} +{% endif %} + + + + {% trans "License key" %} + {% if object.license_key %}{{ object.license_key }}{% else %}{{ None|placeholder }}{% endif %} + + + {% trans "Provider Portal" %} + +{% if object.provider_portal_url %} + {{ object.provider_portal_url }} +{% else %} + {{ None|placeholder }} +{% endif %} + + {% include 'inc/panels/custom_fields.html' %} diff --git a/netbox_slm/tests/test_models.py b/netbox_slm/tests/test_models.py index a96d4ad..8b550c8 100644 --- a/netbox_slm/tests/test_models.py +++ b/netbox_slm/tests/test_models.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext, override from netbox_slm.forms import SoftwareLicenseForm +from netbox_slm.models import RenewalIntervalUnits, SoftwareLicense from tenancy.models import Tenant, TenantGroup from .base import SlmBaseTestCase @@ -74,6 +75,46 @@ class ModelTestCase(SlmBaseTestCase): any("tenant_group" in fieldset.items and "tenant" in fieldset.items for fieldset in form.fieldsets) ) + def test_software_license_form_contains_renewal_and_credentials(self): + form = SoftwareLicenseForm(instance=self.software_license) + for field_name in ( + "lifetime", + "renewal_interval", + "renewal_interval_unit", + "license_file", + "license_key", + "provider_portal_url", + ): + self.assertIn(field_name, form.fields) + + def test_software_license_accepts_optional_type_and_renewal_interval(self): + license = SoftwareLicense( + name="renewable license", + software_product=self.software_product, + renewal_interval=12, + renewal_interval_unit=RenewalIntervalUnits.MONTHS, + ) + license.full_clean() + + def test_software_license_requires_renewal_interval_and_unit_together(self): + license = SoftwareLicense( + name="invalid renewable license", + software_product=self.software_product, + renewal_interval=12, + ) + with self.assertRaisesMessage(ValidationError, "must be specified together"): + license.full_clean() + + def test_lifetime_license_rejects_expiration(self): + license = SoftwareLicense( + name="invalid lifetime license", + software_product=self.software_product, + lifetime=True, + expiration_date="2030-01-01", + ) + with self.assertRaisesMessage(ValidationError, "cannot have a renewal interval or expiration date"): + license.full_clean() + def test_german_translation(self): with override("de"): self.assertEqual("Softwarelizenz", gettext("Software License"))