From adfc2ae72ca6daab59a6f4c6b454d3c2c041f134 Mon Sep 17 00:00:00 2001 From: Louis Date: Mon, 27 Jul 2026 12:16:14 +0200 Subject: [PATCH] feat: extend software license lifecycle data Make license type optional and add lifetime or recurring renewal terms, license file and key storage, and provider portal URLs across the UI, API, filters, imports, translations, and migrations. --- CHANGELOG.md | 3 + README.md | 8 ++- netbox_slm/__init__.py | 2 +- netbox_slm/api/serializers.py | 20 ++++++ netbox_slm/filtersets.py | 7 +- netbox_slm/forms/software_license.py | 41 +++++++++++- netbox_slm/locale/de/LC_MESSAGES/django.mo | Bin 4593 -> 5693 bytes netbox_slm/locale/de/LC_MESSAGES/django.po | 60 ++++++++++++++++++ ...softwarelicense_renewal_and_credentials.py | 51 +++++++++++++++ netbox_slm/models.py | 27 +++++++- netbox_slm/tables.py | 6 ++ .../templates/netbox_slm/softwarelicense.html | 38 +++++++++++ netbox_slm/tests/test_models.py | 41 ++++++++++++ 13 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 netbox_slm/migrations/0012_softwarelicense_renewal_and_credentials.py 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 8303efd8e933cbc6ab43827182ae0d774692dd99..8dc742699b946c2663fb56e3addc154a69722c6b 100644 GIT binary patch literal 5693 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 delta 1622 zcmZA1U1-g59LMpWvvJO5bIx`!=4>=dv(*-xVXe5p7)zK|n~M{FS>_zJqdXrEg$ptM zEf3|XBsWu>8jjFhXsr>uu&8Y*ZahmV#q$2}6Is9gUjN_o_xJt(x4Xrkr^W}fQ`-$c zYxoWE+v`#5@6Ya$#+1_BgDH3ogLnfe<{?!o_M#V`;%I!1qwqCm;X53I!|2CM1~ZL` zn>-$TbQGZ$lu>11C01ZHGSTcqEj)(W=sap&2P%M0)Ve#i-EDn@3ZT!9zp{SD8Jus1 zdCy5{HmtW~Ti4jG_YEj|!{} z<4WyC9w_D-l{UDA3Zx6Q;IVBFpaObh+aImpQJG8Q+Bzr~)h{ir z<*40w_@axr~`^J7%IHtiW8X!W^u}akvF%;UT1$B$W>8MveF5WPFeM>&awL0WZW! zScl_rE9w=+kMN*(aRwDg0(G-{sLXWZB7B01IEUSonGkBc1a&|K)e>Bc`af|3HQtU5 zcn-DiH`MwRPG>xB#`BCi!~)i_O@^)e82y8@dMhq4+y?%QCS|G#O! OCJc-XB}&I-j`#^L*}SR% 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"))