11 Commits
Author SHA1 Message Date
MrBlake 32d62f26b2 feat: repeat license expiration reminders daily 2026-07-29 12:44:22 +02:00
MrBlake 35ddffc903 fix: render expiration notifications in bell menu 2026-07-29 12:38:36 +02:00
MrBlake 919b2d01e5 fix: use NetBox SMTP sender address 2026-07-29 12:26:57 +02:00
MrBlake 157c891cde fix: restore license expiration email delivery 2026-07-29 11:28:55 +02:00
MrBlake 3599dc703e fix: restrict license email notification settings 2026-07-29 11:04:46 +02:00
MrBlake 294e20b891 feat: add license expiration notifications 2026-07-29 11:01:00 +02:00
MrBlake 9f3bddfbb1 fix: handle NetBox form clean return value
Read installation destinations from self.cleaned_data because NetBox 4.6.5 form mixins return None while cleaning newly created objects.
2026-07-27 12:31:26 +02:00
MrBlake 71a5db0ece 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.
2026-07-27 12:28:26 +02:00
MrBlake adfc2ae72c 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.
2026-07-27 12:16:14 +02:00
MrBlake 5887129546 feat: add German localization
Internationalize plugin navigation, forms, tables, model labels, detail templates, choices, and validation messages. Ship a compiled German Django message catalog and bump the plugin version to 1.10.0.
2026-07-24 15:41:35 +02:00
MrBlake 7ca670c717 fix: render tenancy fields in license form
Define an explicit tenancy fieldset and bump the package version to 1.9.1 so Git/Pip upgrades replace older installations.
2026-07-24 15:34:01 +02:00
26 changed files with 1496 additions and 162 deletions
+20
View File
@@ -2,14 +2,34 @@
## [Unreleased]
### Fixed
* Make the required single platform destination explicit when creating an installation
* Fix installation form submission on NetBox v4.6.5 when form mixins return no cleaned-data value
* Prevent the notification log creation flag from shadowing Django's translation function during email delivery
* Use NetBox's configured `EMAIL['FROM_EMAIL']` value as the SMTP sender instead of Django's localhost fallback
* Register the expiration event with a concrete string so NetBox 4.6 can render the notification dropdown
### Added
* 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
* Daily license expiration notifications for users who subscribe to a license
* Default reminders one month and one week before expiration plus an optional custom interval
* Optional SMTP email in addition to mandatory NetBox notifications
### Changed
* Support NetBox v4.6.5
* 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
* Show the plugin under Licences in the main menu
* Restrict SMTP notification activation to administrators and explicitly authorized users
* Repeat NetBox and optional email reminders on every daily job run from the first due interval through expiration
## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
+45 -1
View File
@@ -5,16 +5,28 @@ Lizenzen können optional einer NetBox-Mandantengruppe und einem Mandanten zugeo
Diese Variante ist für **NetBox 4.6.5** vorgesehen.
Die Oberfläche ist auf Englisch und Deutsch verfügbar. Die Sprache richtet sich nach der in NetBox für den
Benutzer ausgewählten Sprache.
## Funktionen
- Softwareprodukte und Hersteller verwalten
- 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
- Ablaufwarnungen standardmäßig einen Monat und eine Woche vor Lizenzende
- Zusätzliche eigene Vorwarnzeit in Tagen, Wochen oder Monaten
- NetBox-Benachrichtigungen und optionaler E-Mail-Versand über die NetBox-SMTP-Konfiguration
- 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
@@ -38,7 +50,8 @@ grep -qxF "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" /opt/netbox/local_
Die Installation muss im Python-Virtual-Environment von NetBox erfolgen:
```bash
/opt/netbox/venv/bin/pip install "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
```
Alternativ können alle lokalen Anforderungen installiert werden:
@@ -47,6 +60,37 @@ Alternativ können alle lokalen Anforderungen installiert werden:
/opt/netbox/venv/bin/pip install -r /opt/netbox/local_requirements.txt
```
Die installierte Version kann anschließend geprüft werden:
```bash
/opt/netbox/venv/bin/pip show netbox-slm
```
Für diese Variante muss dort mindestens Version `1.12.0` stehen.
## Ablaufbenachrichtigungen
Für Lizenzen mit Ablaufdatum sind Benachrichtigungen standardmäßig aktiviert. Die Standardtermine liegen einen
Monat und eine Woche vor dem Ablaufdatum. Pro Lizenz kann zusätzlich eine eigene Vorwarnzeit festgelegt oder die
Standardtermine deaktiviert werden.
Empfänger entscheiden selbst, ob sie informiert werden: Vertrieb, zuständiger Techniker oder andere Benutzer öffnen
die Lizenz und wählen die NetBox-Aktion **Abonnieren**. Mit **Abonnement beenden** können sie die Meldungen wieder
abschalten. Ohne Abonnenten wird keine Nachricht versendet.
Die NetBox-Nachricht wird immer erzeugt. Wird an der Lizenz zusätzlich **E-Mail-Benachrichtigungen** aktiviert, sendet
das Plugin auch eine E-Mail an die im Benutzerkonto hinterlegte Adresse. Dafür muss der SMTP-Versand in NetBox
konfiguriert sein.
Die Aktivierung des E-Mail-Versands darf nur durch Administratoren oder Benutzer mit der Berechtigung
`netbox_slm.manage_softwarelicense_email_notifications` geändert werden. Die eigentlichen SMTP-Zugangsdaten werden
ausschließlich in der NetBox-Serverkonfiguration gepflegt und sind im Plugin nicht einsehbar.
Die Prüfung läuft einmal täglich als NetBox-Systemjob. Sobald der erste konfigurierte Erinnerungstermin erreicht ist,
werden die Abonnenten bei jedem Joblauf erneut informiert: immer über NetBox und bei aktivierter E-Mail-Option
zusätzlich per SMTP. Dies wird bis einschließlich des Ablaufdatums wiederholt. Der NetBox-RQ-Worker muss dafür mit
Scheduler-Unterstützung laufen; dies ist bei der regulären NetBox-Installation mit `rqworker` standardmäßig der Fall.
### 3. Plugin in NetBox aktivieren
In `/opt/netbox/netbox/netbox/configuration.py` ergänzen:
+14 -3
View File
@@ -15,15 +15,16 @@ limitations under the License.
"""
from netbox.plugins import PluginConfig
from django.utils.translation import gettext, gettext_lazy as _
__version__ = "1.9.0"
__version__ = "1.13.0"
class SLMConfig(PluginConfig):
name = "netbox_slm"
verbose_name = "Software Lifecycle Management"
verbose_name = _("Licences")
version = __version__
description = "Software Lifecycle Management Netbox Plugin."
description = _("Licence Management NetBox Plugin.")
author = "ICTU"
author_email = "open-source-projects@ictu.nl"
base_url = "slm"
@@ -35,5 +36,15 @@ class SLMConfig(PluginConfig):
"link_virtualmachine_installations": "right",
}
def ready(self):
super().ready()
from netbox.events import EVENT_TYPE_KIND_WARNING, EventType
from netbox_slm.events import LICENSE_EXPIRING
EventType(LICENSE_EXPIRING, gettext("License expiring"), kind=EVENT_TYPE_KIND_WARNING).register()
from netbox_slm import jobs # noqa: F401
config = SLMConfig
+52 -1
View File
@@ -1,5 +1,6 @@
from rest_framework import serializers
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
from django.utils.translation import gettext_lazy as _
from netbox.api.serializers import NetBoxModelSerializer
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
@@ -23,8 +24,19 @@ 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",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product",
"version",
"installation",
@@ -42,11 +54,50 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
return f"{obj}"
def validate(self, attrs):
request = self.context.get("request")
if (
"email_notifications" in attrs
and request is not None
and not request.user.has_perm("netbox_slm.manage_softwarelicense_email_notifications")
):
raise serializers.ValidationError(
{"email_notifications": _("You do not have permission to change email notifications.")}
)
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."}
{"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.")}
)
custom_interval = attrs.get(
"custom_notification_interval", getattr(self.instance, "custom_notification_interval", None)
)
custom_interval_unit = attrs.get(
"custom_notification_interval_unit",
getattr(self.instance, "custom_notification_interval_unit", ""),
)
if bool(custom_interval) != bool(custom_interval_unit):
raise serializers.ValidationError(
{
"custom_notification_interval": _(
"Custom notification interval and unit must be specified together."
)
}
)
return attrs
+1
View File
@@ -0,0 +1 @@
LICENSE_EXPIRING = "netbox_slm_license_expiring"
+13 -9
View File
@@ -1,5 +1,6 @@
from django.db.models import Q
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter
from django.utils.translation import gettext_lazy as _
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter
from dcim.models import Device, Manufacturer
from netbox.filtersets import NetBoxModelFilterSet
@@ -9,6 +10,7 @@ from netbox_slm.models import (
SoftwareProductInstallation,
SoftwareLicense,
SoftwareReleaseTypes,
RenewalIntervalUnits,
)
from virtualization.models import Cluster, VirtualMachine
from tenancy.models import Tenant, TenantGroup
@@ -55,7 +57,7 @@ class SoftwareProductVersionFilterSet(NetBoxModelFilterSet):
software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(),
label="Software Product",
label=_("Software Product"),
)
class Meta:
@@ -82,12 +84,12 @@ class SoftwareProductInstallationFilterSet(NetBoxModelFilterSet):
device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all())
virtualmachine_id = ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(),
label="Virtual Machine",
label=_("Virtual Machine"),
)
cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all())
software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(),
label="Software Product",
label=_("Software Product"),
)
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
@@ -114,21 +116,23 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
name = CharFilter(lookup_expr="icontains")
description = CharFilter(lookup_expr="icontains")
type = CharFilter(lookup_expr="icontains")
spdx_expression = CharFilter(lookup_expr="icontains", label="SPDX expression")
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(),
label="Software Product",
label=_("Software Product"),
)
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")
tenant_group_id = ModelMultipleChoiceFilter(queryset=TenantGroup.objects.all(), label=_("Tenant Group"))
tenant_id = ModelMultipleChoiceFilter(queryset=Tenant.objects.all(), label=_("Tenant"))
class Meta:
model = SoftwareLicense
fields = ("support",)
fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
def search(self, queryset, name, value):
"""Perform the filtered search."""
+117 -16
View File
@@ -1,5 +1,6 @@
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 _
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import (
@@ -7,6 +8,8 @@ from netbox_slm.models import (
SoftwareProductVersion,
SoftwareProductInstallation,
SoftwareLicense,
RenewalIntervalUnits,
NotificationIntervalUnits,
spdx_license_names,
)
from tenancy.models import Tenant, TenantGroup
@@ -24,16 +27,55 @@ from utilities.forms.widgets import APISelect, DatePicker
class SoftwareLicenseForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
FieldSet(
"name",
"description",
"software_product",
"version",
"installation",
name=_("Software License"),
),
FieldSet("tenant_group", "tenant", name=_("Tenancy")),
FieldSet(
"type",
"spdx_expression",
"stored_location",
"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(
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
name=_("Expiration Notifications"),
),
FieldSet("tags", name=_("Tags")),
)
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label="SPDX expression")
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
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)
custom_notification_interval = IntegerField(required=False, min_value=1)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
label="Software Product",
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
@@ -52,13 +94,13 @@ class SoftwareLicenseForm(NetBoxModelForm):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
label=_("Tenant Group"),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label="Tenant",
label=_("Tenant"),
)
class Meta:
@@ -73,8 +115,19 @@ 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",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version",
"installation",
"tenant_group",
@@ -95,6 +148,11 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
"spdx_expression",
"stored_location",
"support",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"expiration_notifications_enabled",
"email_notifications",
"software_product_id",
"version_id",
"installation_id",
@@ -109,40 +167,47 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
name = CharField(required=False)
description = CharField(required=False)
type = CharField(required=False)
spdx_expression = CharField(required=False, label="SPDX expression")
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)
expiration_notifications_enabled = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
email_notifications = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
label="Version",
label=_("Version"),
)
installation_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductInstallation.objects.all(),
required=False,
label="Installation",
label=_("Installation"),
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
label=_("Tenant Group"),
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group_id"},
label="Tenant",
label=_("Tenant"),
)
class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
support = NullBooleanField(required=False, help_text="Support (values other than 'True' and 'False' are ignored)")
support = NullBooleanField(
required=False, help_text=_("Support (values other than 'True' and 'False' are ignored)")
)
class Meta:
model = SoftwareLicense
@@ -155,8 +220,18 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
"stored_location",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version",
"installation",
"tenant_group",
@@ -175,8 +250,18 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"stored_location_url",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product",
"version",
"installation",
@@ -191,8 +276,14 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"stored_location_url",
"start_date",
"expiration_date",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"custom_notification_interval",
"custom_notification_interval_unit",
"version",
"installation",
"tenant_group",
@@ -203,18 +294,28 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
comments = CommentField()
type = CharField(required=False)
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label="SPDX expression")
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
stored_location = CharField(required=False)
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)
expiration_notifications_enabled = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
default_notification_intervals = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
custom_notification_interval = IntegerField(required=False, min_value=1)
custom_notification_interval_unit = ChoiceField(required=False, choices=NotificationIntervalUnits.choices)
email_notifications = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
@@ -233,11 +334,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label="Tenant Group",
label=_("Tenant Group"),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label="Tenant",
label=_("Tenant"),
)
+2 -1
View File
@@ -1,4 +1,5 @@
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -46,7 +47,7 @@ class SoftwareProductFilterForm(NetBoxModelFilterSetForm):
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label="Manufacturer",
label=_("Manufacturer"),
)
@@ -1,5 +1,6 @@
from django.forms import ValidationError
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Device
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -17,18 +18,32 @@ from virtualization.models import Cluster, VirtualMachine
class SoftwareProductInstallationForm(NetBoxModelForm):
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(
queryset=VirtualMachine.objects.all(),
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(
queryset=SoftwareProduct.objects.all(),
required=True,
label="Software Product",
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
@@ -54,11 +69,22 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
software_product = self.cleaned_data["software_product"]
if version not in software_product.softwareproductversion_set.all():
raise ValidationError(
f"Version '{version}' doesn't exist on {software_product}, make sure you've "
f"selected a compatible version or first select the software product."
_("Version '%(version)s' does not belong to %(product)s. Select a compatible version.")
% {"version": version, "product": software_product}
)
return version
def clean(self):
super().clean()
destinations = [
self.cleaned_data.get("device"),
self.cleaned_data.get("virtualmachine"),
self.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 self.cleaned_data
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProductInstallation
@@ -73,27 +99,27 @@ class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label="Device",
label=_("Device"),
)
virtualmachine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label="Virtual Machine",
label=_("Virtual Machine"),
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label="Cluster",
label=_("Cluster"),
)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
label="Version",
label=_("Version"),
)
@@ -122,13 +148,13 @@ class SoftwareProductInstallationBulkEditForm(NetBoxModelBulkEditForm):
virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label="Virtual Machine",
label=_("Virtual Machine"),
)
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
+8 -5
View File
@@ -1,5 +1,6 @@
from django.forms import DateField, CharField, MultipleChoiceField
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -23,7 +24,7 @@ class SoftwareProductVersionForm(NetBoxModelForm):
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
label="Software Product",
label=_("Software Product"),
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}),
)
@@ -64,17 +65,19 @@ class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm):
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label="Manufacturer",
label=_("Manufacturer"),
)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm):
release_type = CharField(help_text=f"Release type (possible values: {SoftwareReleaseTypes.values})")
release_type = CharField(
help_text=_("Release type (possible values: %(values)s)") % {"values": SoftwareReleaseTypes.values}
)
class Meta:
model = SoftwareProductVersion
@@ -122,5 +125,5 @@ class SoftwareProductVersionBulkEditForm(NetBoxModelBulkEditForm):
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label="Software Product",
label=_("Software Product"),
)
+91
View File
@@ -0,0 +1,91 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.mail import send_mail
from django.utils import timezone
from django.utils.translation import gettext as _
from core.choices import JobIntervalChoices
from extras.models import Notification
from netbox.jobs import JobRunner, system_job
from netbox_slm.events import LICENSE_EXPIRING
from netbox_slm.models import SoftwareLicense, SoftwareLicenseNotificationLog
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class LicenseExpirationNotificationJob(JobRunner):
class Meta:
name = "Software License Expiration Notifications"
def run(self, *args, **kwargs):
today = timezone.localdate()
licenses = SoftwareLicense.objects.filter(
expiration_notifications_enabled=True,
lifetime=False,
expiration_date__gte=today,
).prefetch_related("subscriptions__user")
for license in licenses:
due_dates = [date for date in license.get_notification_dates() if date <= today]
if not due_dates:
continue
for subscription in license.subscriptions.all():
user = subscription.user
if not user.is_active:
continue
# If notifications were enabled late, send only the most recent reminder instead of one per elapsed
# interval. Normally this still produces one reminder at each configured threshold.
self._notify(license, user, max(due_dates))
def _notify(self, license, user, notification_date):
log = SoftwareLicenseNotificationLog.objects.get_or_create(
license=license,
user=user,
expiration_date=license.expiration_date,
notification_date=notification_date,
)[0]
now = timezone.now()
object_type = ContentType.objects.get_for_model(license)
Notification.objects.update_or_create(
object_type=object_type,
object_id=license.pk,
user=user,
defaults={
"created": now,
"object_repr": Notification.get_object_repr(license),
"event_type": LICENSE_EXPIRING,
"read": None,
},
)
log.netbox_sent_at = now
log.save(update_fields=("netbox_sent_at",))
self.logger.info(f"Created NetBox expiration notification for license {license.pk} and user {user}")
if license.email_notifications and user.email:
subject = _("License %(license)s expires on %(date)s") % {
"license": license,
"date": license.expiration_date,
}
body = _(
"The software license '%(license)s' for '%(product)s' expires on %(date)s."
) % {
"license": license,
"product": license.software_product,
"date": license.expiration_date,
}
if license.tenant:
body += "\n" + _("Tenant: %(tenant)s") % {"tenant": license.tenant}
if license.provider_portal_url:
body += "\n" + _("Provider portal: %(url)s") % {"url": license.provider_portal_url}
try:
from_email = settings.SERVER_EMAIL or settings.EMAIL_HOST_USER
send_mail(subject, body, from_email, [user.email], fail_silently=False)
except Exception:
self.logger.exception(f"Failed to send expiration email for license {license.pk} to user {user}")
else:
log.email_sent_at = now
log.save(update_fields=("email_sent_at",))
self.logger.info(f"Sent expiration email for license {license.pk} to user {user}")
Binary file not shown.
+419
View File
@@ -0,0 +1,419 @@
msgid ""
msgstr ""
"Project-Id-Version: netbox-slm 1.9.1\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Add"
msgstr "Hinzufügen"
msgid "Import"
msgstr "Importieren"
msgid "Software Lifecycle"
msgstr "Software-Lebenszyklus"
msgid "Software Products"
msgstr "Softwareprodukte"
msgid "Software Product"
msgstr "Softwareprodukt"
msgid "Software Product Version"
msgstr "Softwareproduktversion"
msgid "Software Product Installation"
msgstr "Softwareproduktinstallation"
msgid "Software License"
msgstr "Softwarelizenz"
msgid "License Details"
msgstr "Lizenzdetails"
msgid "Versions"
msgstr "Versionen"
msgid "Version"
msgstr "Version"
msgid "Installations"
msgstr "Installationen"
msgid "Installation"
msgstr "Installation"
msgid "Add an installation"
msgstr "Installation hinzufügen"
msgid "Licenses"
msgstr "Lizenzen"
msgid "Tenancy"
msgstr "Mandantenzuordnung"
msgid "Tenant Group"
msgstr "Mandantengruppe"
msgid "Tenant"
msgstr "Mandant"
msgid "Manufacturer"
msgstr "Hersteller"
msgid "Device"
msgstr "Gerät"
msgid "Virtual Machine"
msgstr "Virtuelle Maschine"
msgid "Cluster"
msgstr "Cluster"
msgid "Name"
msgstr "Name"
msgid "Description"
msgstr "Beschreibung"
msgid "Type"
msgstr "Typ"
msgid "SPDX expression"
msgstr "SPDX-Ausdruck"
msgid "Stored location"
msgstr "Ablageort"
msgid "Start date"
msgstr "Startdatum"
msgid "Expiration date"
msgstr "Ablaufdatum"
msgid "Support"
msgstr "Support"
msgid "License amount"
msgstr "Lizenzanzahl"
msgid "Release date"
msgstr "Veröffentlichungsdatum"
msgid "Documentation URL"
msgstr "Dokumentations-URL"
msgid "End of support"
msgstr "Supportende"
msgid "Filename"
msgstr "Dateiname"
msgid "File checksum"
msgstr "Dateiprüfsumme"
msgid "Release type"
msgstr "Veröffentlichungstyp"
msgid "Release candidate"
msgstr "Veröffentlichungskandidat"
msgid "Stable release"
msgstr "Stabile Veröffentlichung"
msgid "Alpha"
msgstr "Alpha"
msgid "Beta"
msgstr "Beta"
msgid "Tags"
msgstr "Tags"
msgid "None"
msgstr "Keine"
msgid "Link"
msgstr "Link"
msgid "Support (values other than 'True' and 'False' are ignored)"
msgstr "Support (andere Werte als True und False werden ignoriert)"
msgid "Release type (possible values: %(values)s)"
msgstr "Veröffentlichungstyp (mögliche Werte: %(values)s)"
msgid "Version '%(version)s' does not belong to %(product)s. Select a compatible version."
msgstr "Version %(version)s gehört nicht zu %(product)s. Wähle eine kompatible Version aus."
msgid "Installation requires exactly one platform destination."
msgstr "Eine Installation benötigt genau ein Plattformziel."
msgid "%(value)s is not a known SPDX license expression"
msgstr "%(value)s ist kein bekannter SPDX-Lizenzausdruck"
msgid "The selected tenant does not belong to the selected tenant group."
msgstr "Der ausgewählte Mandant gehört nicht zur ausgewählten Mandantengruppe."
msgid "Software Lifecycle Management"
msgstr "Software-Lebenszyklusverwaltung"
msgid "Software Lifecycle Management NetBox Plugin."
msgstr "NetBox-Plugin zur Verwaltung des Software-Lebenszyklus."
msgid "name"
msgstr "Name"
msgid "comments"
msgstr "Kommentare"
msgid "description"
msgstr "Beschreibung"
msgid "manufacturer"
msgstr "Hersteller"
msgid "software product"
msgstr "Softwareprodukt"
msgid "software products"
msgstr "Softwareprodukte"
msgid "release date"
msgstr "Veröffentlichungsdatum"
msgid "documentation URL"
msgstr "Dokumentations-URL"
msgid "end of support"
msgstr "Supportende"
msgid "filename"
msgstr "Dateiname"
msgid "file checksum"
msgstr "Dateiprüfsumme"
msgid "file link"
msgstr "Dateilink"
msgid "release type"
msgstr "Veröffentlichungstyp"
msgid "software product version"
msgstr "Softwareproduktversion"
msgid "software product versions"
msgstr "Softwareproduktversionen"
msgid "device"
msgstr "Gerät"
msgid "virtual machine"
msgstr "Virtuelle Maschine"
msgid "cluster"
msgstr "Cluster"
msgid "version"
msgstr "Version"
msgid "software product installation"
msgstr "Softwareproduktinstallation"
msgid "software product installations"
msgstr "Softwareproduktinstallationen"
msgid "type"
msgstr "Typ"
msgid "stored location"
msgstr "Ablageort"
msgid "stored location URL"
msgstr "Ablageort-URL"
msgid "start date"
msgstr "Startdatum"
msgid "expiration date"
msgstr "Ablaufdatum"
msgid "support"
msgstr "Support"
msgid "license amount"
msgstr "Lizenzanzahl"
msgid "installation"
msgstr "Installation"
msgid "tenant group"
msgstr "Mandantengruppe"
msgid "tenant"
msgstr "Mandant"
msgid "software license"
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."
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."
msgid "Weeks"
msgstr "Wochen"
msgid "expiration notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "default notification intervals"
msgstr "Standard-Benachrichtigungsintervalle"
msgid "custom notification interval"
msgstr "Eigene Vorwarnzeit"
msgid "custom notification interval unit"
msgstr "Einheit der eigenen Vorwarnzeit"
msgid "email notifications"
msgstr "E-Mail-Benachrichtigungen"
msgid "Expiration Notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "Custom notification interval and unit must be specified together."
msgstr "Eigene Vorwarnzeit und Einheit müssen gemeinsam angegeben werden."
msgid "License expiring"
msgstr "Lizenz läuft ab"
msgid "License %(license)s expires on %(date)s"
msgstr "Lizenz %(license)s läuft am %(date)s ab"
msgid "The software license '%(license)s' for '%(product)s' expires on %(date)s."
msgstr "Die Softwarelizenz %(license)s für %(product)s läuft am %(date)s ab."
msgid "Tenant: %(tenant)s"
msgstr "Mandant: %(tenant)s"
msgid "Provider portal: %(url)s"
msgstr "Anbieterportal: %(url)s"
msgid "Expiration notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "Default notification intervals"
msgstr "Standard-Benachrichtigungsintervalle"
msgid "One month and one week before expiration"
msgstr "Einen Monat und eine Woche vor Ablauf"
msgid "Disabled"
msgstr "Deaktiviert"
msgid "Enabled"
msgstr "Aktiviert"
msgid "Custom notification interval"
msgstr "Eigene Vorwarnzeit"
msgid "Email notifications"
msgstr "E-Mail-Benachrichtigungen"
msgid "Notification recipients"
msgstr "Benachrichtigungsempfänger"
msgid "No subscribers. Users can subscribe to this license using the Subscribe action."
msgstr "Keine Abonnenten. Benutzer können diese Lizenz über die Aktion Abonnieren abonnieren."
msgid "Licences"
msgstr "Lizenzen"
msgid "Licence Management NetBox Plugin."
msgstr "NetBox-Plugin zur Lizenzverwaltung."
msgid "Can manage license email notifications"
msgstr "Kann E-Mail-Benachrichtigungen für Lizenzen verwalten"
msgid "You do not have permission to change email notifications."
msgstr "Sie haben keine Berechtigung, E-Mail-Benachrichtigungen zu ändern."
msgid "Only administrators or explicitly authorized users can change this setting."
msgstr "Nur Administratoren oder ausdrücklich berechtigte Benutzer können diese Einstellung ändern."
@@ -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),
),
]
@@ -0,0 +1,77 @@
import django.core.validators
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("netbox_slm", "0012_softwarelicense_renewal_and_credentials"),
]
operations = [
migrations.AddField(
model_name="softwarelicense",
name="expiration_notifications_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="softwarelicense",
name="default_notification_intervals",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="softwarelicense",
name="custom_notification_interval",
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AddField(
model_name="softwarelicense",
name="custom_notification_interval_unit",
field=models.CharField(
blank=True,
choices=[("days", "Days"), ("weeks", "Weeks"), ("months", "Months")],
max_length=8,
),
),
migrations.AddField(
model_name="softwarelicense",
name="email_notifications",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="SoftwareLicenseNotificationLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("expiration_date", models.DateField()),
("notification_date", models.DateField()),
("netbox_sent_at", models.DateTimeField(blank=True, null=True)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
(
"license",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_logs",
to="netbox_slm.softwarelicense",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="softwarelicensenotificationlog",
constraint=models.UniqueConstraint(
fields=("license", "user", "expiration_date", "notification_date"),
name="netbox_slm_license_notification_unique",
),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("netbox_slm", "0013_license_expiration_notifications"),
]
operations = [
migrations.AlterModelOptions(
name="softwarelicense",
options={
"permissions": [
(
"manage_softwarelicense_email_notifications",
"Can manage license email notifications",
)
],
"verbose_name": "software license",
"verbose_name_plural": "software licenses",
},
),
]
+199 -42
View File
@@ -1,7 +1,13 @@
import calendar
from datetime import timedelta
from django.conf import settings
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
from django.utils.translation import gettext_lazy as _
from license_expression import Licensing, get_spdx_licensing
from netbox.models import NetBoxModel
@@ -21,14 +27,20 @@ class LaxURLField(models.URLField):
class SoftwareProduct(NetBoxModel):
name = models.CharField(max_length=128)
comments = models.TextField(blank=True)
name = models.CharField(_("name"), max_length=128)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey(to="dcim.Manufacturer", on_delete=models.PROTECT, null=True, blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey(
to="dcim.Manufacturer", verbose_name=_("manufacturer"), on_delete=models.PROTECT, null=True, blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product")
verbose_name_plural = _("software products")
def __str__(self):
return self.name
@@ -44,25 +56,38 @@ class SoftwareProduct(NetBoxModel):
class SoftwareReleaseTypes(models.TextChoices):
ALPHA = "A", "Alpha"
BETA = "B", "Beta"
RELEASE_CANDIDATE = "RC", "Release candidate"
STABLE = "S", "Stable release"
ALPHA = "A", _("Alpha")
BETA = "B", _("Beta")
RELEASE_CANDIDATE = "RC", _("Release candidate")
STABLE = "S", _("Stable release")
class RenewalIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
MONTHS = "months", _("Months")
YEARS = "years", _("Years")
class NotificationIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
WEEKS = "weeks", _("Weeks")
MONTHS = "months", _("Months")
class SoftwareProductVersion(NetBoxModel):
name = models.CharField(max_length=64)
comments = models.TextField(blank=True)
name = models.CharField(_("name"), max_length=64)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True)
release_date = models.DateField(null=True, blank=True)
documentation_url = LaxURLField(max_length=1024, null=True, blank=True)
end_of_support = models.DateField(null=True, blank=True)
filename = models.CharField(max_length=64, null=True, blank=True)
file_checksum = models.CharField(max_length=128, null=True, blank=True)
file_link = LaxURLField(max_length=1024, null=True, blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
release_date = models.DateField(_("release date"), null=True, blank=True)
documentation_url = LaxURLField(_("documentation URL"), max_length=1024, null=True, blank=True)
end_of_support = models.DateField(_("end of support"), null=True, blank=True)
filename = models.CharField(_("filename"), max_length=64, null=True, blank=True)
file_checksum = models.CharField(_("file checksum"), max_length=128, null=True, blank=True)
file_link = LaxURLField(_("file link"), max_length=1024, null=True, blank=True)
release_type = models.CharField(
_("release type"),
max_length=3,
choices=SoftwareReleaseTypes.choices,
default=SoftwareReleaseTypes.STABLE,
@@ -70,11 +95,16 @@ class SoftwareProductVersion(NetBoxModel):
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct",
verbose_name=_("software product"),
on_delete=models.PROTECT,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product version")
verbose_name_plural = _("software product versions")
def __str__(self):
return self.name
@@ -88,15 +118,27 @@ class SoftwareProductVersion(NetBoxModel):
class SoftwareProductInstallation(NetBoxModel):
comments = models.TextField(blank=True)
comments = models.TextField(_("comments"), blank=True)
device = models.ForeignKey(to="dcim.Device", on_delete=models.PROTECT, null=True, blank=True)
virtualmachine = models.ForeignKey(
to="virtualization.VirtualMachine", on_delete=models.PROTECT, null=True, blank=True
device = models.ForeignKey(
to="dcim.Device", verbose_name=_("device"), on_delete=models.PROTECT, null=True, blank=True
)
virtualmachine = models.ForeignKey(
to="virtualization.VirtualMachine",
verbose_name=_("virtual machine"),
on_delete=models.PROTECT,
null=True,
blank=True,
)
cluster = models.ForeignKey(
to="virtualization.Cluster", verbose_name=_("cluster"), on_delete=models.PROTECT, null=True, blank=True
)
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT
)
version = models.ForeignKey(
to="netbox_slm.SoftwareProductVersion", verbose_name=_("version"), on_delete=models.PROTECT
)
cluster = models.ForeignKey(to="virtualization.Cluster", on_delete=models.PROTECT, null=True, blank=True)
software_product = models.ForeignKey(to="netbox_slm.SoftwareProduct", on_delete=models.PROTECT)
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT)
objects = RestrictedQuerySet.as_manager()
@@ -104,6 +146,8 @@ class SoftwareProductInstallation(NetBoxModel):
return f"{self.pk} ({self.platform})"
class Meta:
verbose_name = _("software product installation")
verbose_name_plural = _("software product installations")
constraints = [
models.CheckConstraint(
name="%(app_label)s_%(class)s_platform",
@@ -112,7 +156,9 @@ class SoftwareProductInstallation(NetBoxModel):
| models.Q(device__isnull=True, virtualmachine__isnull=False, cluster__isnull=True)
| 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."
),
)
]
@@ -141,30 +187,67 @@ def spdx_license_names():
def validate_spdx_expression(value):
expression_info = spdx_licensing.validate(value)
if expression_info.errors:
raise ValidationError(f"{value} is not a known SPDX license expression")
raise ValidationError(_("%(value)s is not a known SPDX license expression") % {"value": value})
class SoftwareLicense(NetBoxModel):
name = models.CharField(max_length=128)
comments = models.TextField(blank=True)
name = models.CharField(_("name"), max_length=128)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True)
type = models.CharField(max_length=128)
spdx_expression = models.CharField(max_length=64, null=True, blank=True, validators=[validate_spdx_expression])
stored_location = models.CharField(max_length=255, null=True, blank=True)
stored_location_url = LaxURLField(max_length=1024, null=True, blank=True)
start_date = models.DateField(null=True, blank=True)
expiration_date = models.DateField(null=True, blank=True)
support = models.BooleanField(default=None, null=True, blank=True)
license_amount = models.PositiveIntegerField(default=None, null=True, blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
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]
)
stored_location = models.CharField(_("stored location"), max_length=255, null=True, blank=True)
stored_location_url = LaxURLField(_("stored location URL"), max_length=1024, null=True, blank=True)
start_date = models.DateField(_("start date"), null=True, blank=True)
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)
expiration_notifications_enabled = models.BooleanField(_("expiration notifications"), default=True)
default_notification_intervals = models.BooleanField(_("default notification intervals"), default=True)
custom_notification_interval = models.PositiveIntegerField(
_("custom notification interval"), null=True, blank=True, validators=[MinValueValidator(1)]
)
custom_notification_interval_unit = models.CharField(
_("custom notification interval unit"),
max_length=8,
choices=NotificationIntervalUnits.choices,
blank=True,
)
email_notifications = models.BooleanField(_("email notifications"), default=False)
software_product = models.ForeignKey(to="netbox_slm.SoftwareProduct", on_delete=models.PROTECT)
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT, null=True, blank=True)
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT
)
version = models.ForeignKey(
to="netbox_slm.SoftwareProductVersion",
verbose_name=_("version"),
on_delete=models.PROTECT,
null=True,
blank=True,
)
installation = models.ForeignKey(
to="netbox_slm.SoftwareProductInstallation", on_delete=models.SET_NULL, null=True, blank=True
to="netbox_slm.SoftwareProductInstallation",
verbose_name=_("installation"),
on_delete=models.SET_NULL,
null=True,
blank=True,
)
tenant_group = models.ForeignKey(
to="tenancy.TenantGroup",
verbose_name=_("tenant group"),
on_delete=models.PROTECT,
null=True,
blank=True,
@@ -172,6 +255,7 @@ class SoftwareLicense(NetBoxModel):
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
verbose_name=_("tenant"),
on_delete=models.PROTECT,
null=True,
blank=True,
@@ -180,6 +264,13 @@ class SoftwareLicense(NetBoxModel):
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software license")
verbose_name_plural = _("software licenses")
permissions = [
("manage_softwarelicense_email_notifications", _("Can manage license email notifications")),
]
def __str__(self):
return self.name
@@ -189,10 +280,76 @@ class SoftwareLicense(NetBoxModel):
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."})
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.")}
)
if bool(self.custom_notification_interval) != bool(self.custom_notification_interval_unit):
raise ValidationError(
{
"custom_notification_interval": _(
"Custom notification interval and unit must be specified together."
)
}
)
@staticmethod
def _subtract_months(value, months):
month_index = value.year * 12 + value.month - 1 - months
year, zero_based_month = divmod(month_index, 12)
month = zero_based_month + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return value.replace(year=year, month=month, day=day)
def get_notification_dates(self):
"""Return unique reminder dates for the current expiration date."""
if not self.expiration_notifications_enabled or self.lifetime or not self.expiration_date:
return []
dates = set()
if self.default_notification_intervals:
dates.add(self._subtract_months(self.expiration_date, 1))
dates.add(self.expiration_date - timedelta(weeks=1))
if self.custom_notification_interval and self.custom_notification_interval_unit:
amount = self.custom_notification_interval
if self.custom_notification_interval_unit == NotificationIntervalUnits.MONTHS:
dates.add(self._subtract_months(self.expiration_date, amount))
elif self.custom_notification_interval_unit == NotificationIntervalUnits.WEEKS:
dates.add(self.expiration_date - timedelta(weeks=amount))
else:
dates.add(self.expiration_date - timedelta(days=amount))
return sorted(dates)
@property
def stored_location_txt(self):
if self.stored_location_url and not self.stored_location:
return "Link"
return _("Link")
return self.stored_location
class SoftwareLicenseNotificationLog(models.Model):
license = models.ForeignKey(
to="netbox_slm.SoftwareLicense", on_delete=models.CASCADE, related_name="notification_logs"
)
user = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
expiration_date = models.DateField()
notification_date = models.DateField()
netbox_sent_at = models.DateTimeField(null=True, blank=True)
email_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("license", "user", "expiration_date", "notification_date"),
name="netbox_slm_license_notification_unique",
)
]
+14 -13
View File
@@ -1,21 +1,22 @@
from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config
from django.utils.translation import gettext_lazy as _
from . import SLMConfig
slm_items = (
PluginMenuItem(
link="plugins:netbox_slm:softwareproduct_list",
link_text="Software Products",
link_text=_("Software Products"),
permissions=["netbox_slm.add_softwareproduct"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_add",
"Add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproduct"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_bulk_import",
"Import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproduct"],
),
@@ -23,18 +24,18 @@ slm_items = (
),
PluginMenuItem(
link="plugins:netbox_slm:softwareproductversion_list",
link_text="Versions",
link_text=_("Versions"),
permissions=["netbox_slm.add_softwareproductversion"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_add",
"Add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductversion"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_bulk_import",
"Import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductversion"],
),
@@ -42,18 +43,18 @@ slm_items = (
),
PluginMenuItem(
link="plugins:netbox_slm:softwareproductinstallation_list",
link_text="Installations",
link_text=_("Installations"),
permissions=["netbox_slm.add_softwareproductinstallation"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_add",
"Add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductinstallation"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_bulk_import",
"Import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductinstallation"],
),
@@ -61,18 +62,18 @@ slm_items = (
),
PluginMenuItem(
link="plugins:netbox_slm:softwarelicense_list",
link_text="Licenses",
link_text=_("Licenses"),
permissions=["netbox_slm.add_softwarelicense"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwarelicense_add",
"Add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwarelicense"],
),
PluginMenuButton(
"plugins:netbox_slm:softwarelicense_bulk_import",
"Import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwarelicense"],
),
@@ -82,7 +83,7 @@ slm_items = (
if get_plugin_config("netbox_slm", "top_level_menu"):
menu = PluginMenu(
label="Software Lifecycle",
label=_("Licences"),
groups=((SLMConfig.verbose_name, slm_items),),
icon_class="mdi mdi-content-save",
)
+15 -5
View File
@@ -1,5 +1,6 @@
import django_tables2 as tables
from django.db.models import Count, F, Value
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, ToggleColumn, columns
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
@@ -45,7 +46,7 @@ class SoftwareProductVersionTable(NetBoxTable):
pk = ToggleColumn()
name = tables.LinkColumn()
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
installations = tables.Column(accessor="get_installation_count")
@@ -89,7 +90,7 @@ class SoftwareProductInstallationTable(NetBoxTable):
platform = tables.Column(accessor="platform", linkify=True)
type = tables.Column(accessor="render_type")
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
version = tables.Column(accessor="version", linkify=True)
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list")
@@ -137,15 +138,16 @@ class SoftwareLicenseTable(NetBoxTable):
name = tables.LinkColumn()
type = tables.Column()
spdx_expression = tables.Column(verbose_name="SPDX expression")
spdx_expression = tables.Column(verbose_name=_("SPDX expression"))
stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
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_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")
@@ -160,6 +162,9 @@ class SoftwareLicenseTable(NetBoxTable):
"stored_location",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"manufacturer",
"software_product",
"version",
@@ -168,6 +173,9 @@ class SoftwareLicenseTable(NetBoxTable):
"tenant",
"support",
"license_amount",
"provider_portal_url",
"expiration_notifications_enabled",
"email_notifications",
"tags",
)
default_columns = (
@@ -180,6 +188,8 @@ class SoftwareLicenseTable(NetBoxTable):
"tenant_group",
"tenant",
"expiration_date",
"lifetime",
"expiration_notifications_enabled",
"tags",
)
@@ -1,12 +1,13 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">
Installations
{% trans "Installations" %}
{% if perms.netbox_slm.add_softwareproductinstallation %}
<div class="card-actions">
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation_add' %}?{{ search_obj }}={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add an installation
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an installation" %}
</a>
</div>
{% endif %}
@@ -3,53 +3,54 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Software License
{% trans "Software License" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.type }}</td>
</tr>
<tr>
<th scope="row">SPDX expression</th>
<th scope="row">{% trans "SPDX expression" %}</th>
<td>{{ object.spdx_expression }}</td>
</tr>
<tr>
<th scope="row">Software Product</th>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">Version</th>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td>
</tr>
<tr>
<th scope="row">Installation</th>
<th scope="row">{% trans "Installation" %}</th>
<td>{{ object.installation|linkify }}</td>
</tr>
<tr>
<th scope="row">Tenant Group</th>
<th scope="row">{% trans "Tenant Group" %}</th>
<td>{{ object.tenant_group|linkify }}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify }}</td>
</tr>
<tr>
<th scope="row">Stored location</th>
<th scope="row">{% trans "Stored location" %}</th>
{% if object.stored_location_url %}
<td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td>
{% else %}
@@ -57,21 +58,97 @@
{% endif %}
</tr>
<tr>
<th scope="row">Start date</th>
<th scope="row">{% trans "Start date" %}</th>
<td>{{ object.start_date }}</td>
</tr>
<tr>
<th scope="row">Expiration date</th>
<th scope="row">{% trans "Expiration date" %}</th>
<td>{{ object.expiration_date }}</td>
</tr>
<tr>
<th scope="row">Support</th>
<th scope="row">{% trans "Lifetime license" %}</th>
<td>{% if object.lifetime %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Renewal interval" %}</th>
<td>
{% if object.renewal_interval %}
{{ object.renewal_interval }} {{ object.get_renewal_interval_unit_display }}
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Support" %}</th>
<td>{{ object.support }}</td>
</tr>
<tr>
<th scope="row">License amount</th>
<th scope="row">{% trans "License amount" %}</th>
<td>{{ object.license_amount }}</td>
</tr>
<tr>
<th scope="row">{% trans "License file" %}</th>
<td>
{% if object.license_file %}
<a href="{{ object.license_file.url }}">{% trans "Download license file" %}</a>
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "License key" %}</th>
<td>{% if object.license_key %}<code>{{ object.license_key }}</code>{% else %}{{ None|placeholder }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider Portal" %}</th>
<td>
{% if object.provider_portal_url %}
<a href="{{ object.provider_portal_url }}">{{ object.provider_portal_url }}</a>
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Expiration notifications" %}</th>
<td>{% if object.expiration_notifications_enabled %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Default notification intervals" %}</th>
<td>
{% if object.default_notification_intervals %}
{% trans "One month and one week before expiration" %}
{% else %}
{% trans "Disabled" %}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Custom notification interval" %}</th>
<td>
{% if object.custom_notification_interval %}
{{ object.custom_notification_interval }} {{ object.get_custom_notification_interval_unit_display }}
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Email notifications" %}</th>
<td>{% if object.email_notifications %}{% trans "Enabled" %}{% else %}{% trans "Disabled" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Notification recipients" %}</th>
<td>
{% for subscription in object.subscriptions.all %}
{{ subscription.user|linkify }}{% if not forloop.last %}, {% endif %}
{% empty %}
{% trans "No subscribers. Users can subscribe to this license using the Subscribe action." %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
@@ -3,60 +3,61 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Software Product
{% trans "Software Product" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">Manufacturer</th>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">Versions</th>
<th scope="row">{% trans "Versions" %}</th>
<td>
{% for version in object.softwareproductversion_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm">
{{ version }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">Installations</th>
<th scope="row">{% trans "Installations" %}</th>
<td>
{% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">Licenses</th>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
@@ -3,48 +3,49 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Software Product Installation
{% trans "Software Product Installation" %}
</h5>
<table class="table table-hover attr-table">
{% if object.device %}
<tr>
<th scope="row">Device</th>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
{% elif object.virtualmachine %}
<tr>
<th scope="row">Virtual Machine</th>
<th scope="row">{% trans "Virtual Machine" %}</th>
<td>{{ object.virtualmachine|linkify }}</td>
</tr>
{% else %}
<tr>
<th scope="row">Cluster</th>
<th scope="row">{% trans "Cluster" %}</th>
<td>{{ object.cluster|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">Software Product</th>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">Version</th>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td>
</tr>
<tr>
<th scope="row">Licenses</th>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
@@ -3,37 +3,38 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Software Product Version
{% trans "Software Product Version" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">Manufacturer</th>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.software_product.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">Software Product</th>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">Release date</th>
<th scope="row">{% trans "Release date" %}</th>
<td>{{ object.release_date }}</td>
</tr>
<tr>
<th scope="row">Documentation url</th>
<th scope="row">{% trans "Documentation URL" %}</th>
{% if object.documentation_url %}
<td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td>
{% else %}
@@ -41,11 +42,11 @@
{% endif %}
</tr>
<tr>
<th scope="row">End of support</th>
<th scope="row">{% trans "End of support" %}</th>
<td>{{ object.end_of_support }}</td>
</tr>
<tr>
<th scope="row">Filename</th>
<th scope="row">{% trans "Filename" %}</th>
{% if object.file_link %}
<td><a href="{{ object.file_link }}">{{ object.filename }}</a></td>
{% else %}
@@ -53,34 +54,34 @@
{% endif %}
</tr>
<tr>
<th scope="row">File checksum</th>
<th scope="row">{% trans "File checksum" %}</th>
<td>{{ object.file_checksum }}</td>
</tr>
<tr>
<th scope="row">Release type</th>
<th scope="row">{% trans "Release type" %}</th>
<td>{{ object.get_release_type_display }}</td>
</tr>
<tr>
<th scope="row">Installations</th>
<th scope="row">{% trans "Installations" %}</th>
<td>
{% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">Licenses</th>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
None
{% trans "None" %}
{% endfor %}
</td>
</tr>
+137
View File
@@ -1,4 +1,22 @@
from datetime import date, timedelta
from unittest.mock import Mock, patch
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import override_settings
from django.utils import timezone
from django.utils.translation import gettext, override
from extras.models import Notification, Subscription
from netbox_slm.forms import SoftwareLicenseForm, SoftwareProductInstallationForm
from netbox_slm.jobs import LicenseExpirationNotificationJob
from netbox_slm.models import (
NotificationIntervalUnits,
RenewalIntervalUnits,
SoftwareLicense,
SoftwareLicenseNotificationLog,
)
from tenancy.models import Tenant, TenantGroup
from .base import SlmBaseTestCase
@@ -53,6 +71,25 @@ class ModelTestCase(SlmBaseTestCase):
self.assertEqual("cluster", self.software_product_installation.render_type())
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):
self.assertEqual("Link", self.software_license.stored_location_txt)
@@ -64,6 +101,106 @@ class ModelTestCase(SlmBaseTestCase):
self.assertEqual(self.tenant_group, self.software_license.tenant_group)
self.assertEqual(self.tenant, self.software_license.tenant)
def test_software_license_form_renders_tenancy_fields(self):
form = SoftwareLicenseForm(instance=self.software_license)
self.assertIn("tenant_group", form.fields)
self.assertIn("tenant", form.fields)
self.assertTrue(
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_license_notification_dates(self):
license = SoftwareLicense(
name="expiring license",
software_product=self.software_product,
expiration_date=date(2027, 3, 31),
custom_notification_interval=6,
custom_notification_interval_unit=NotificationIntervalUnits.MONTHS,
)
self.assertEqual(
[date(2026, 9, 30), date(2027, 2, 28), date(2027, 3, 24)],
license.get_notification_dates(),
)
@override_settings(SERVER_EMAIL="netbox@example.com", EMAIL_HOST_USER="smtp-user")
@patch("netbox_slm.jobs.send_mail")
def test_expiration_job_notifies_subscribers_on_every_run(self, send_mail):
user = get_user_model().objects.create_user(
username="license-recipient",
email="recipient@example.com",
)
license = SoftwareLicense.objects.create(
name="expiring subscribed license",
software_product=self.software_product,
expiration_date=timezone.localdate() + timedelta(days=1),
default_notification_intervals=False,
custom_notification_interval=1,
custom_notification_interval_unit=NotificationIntervalUnits.DAYS,
email_notifications=True,
)
Subscription.objects.create(
user=user,
object_type=ContentType.objects.get_for_model(license),
object_id=license.pk,
)
runner = LicenseExpirationNotificationJob.__new__(LicenseExpirationNotificationJob)
runner.logger = Mock()
runner.run()
runner.run()
notification = Notification.objects.get(user=user, object_id=license.pk)
self.assertIsInstance(str(notification.event), str)
self.assertEqual(1, SoftwareLicenseNotificationLog.objects.filter(user=user, license=license).count())
self.assertEqual(2, send_mail.call_count)
self.assertEqual("netbox@example.com", send_mail.call_args.args[2])
def test_german_translation(self):
with override("de"):
self.assertEqual("Softwarelizenz", gettext("Software License"))
self.assertEqual("Mandantengruppe", gettext("Tenant Group"))
self.assertEqual("Lizenzen", gettext("Licenses"))
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)
+28 -3
View File
@@ -1,9 +1,34 @@
from netbox.views import generic
from django.utils.translation import gettext_lazy as _
from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareLicense
from utilities.views import register_model_view
class EmailNotificationPermissionMixin:
"""Prevent users without the dedicated permission from changing SMTP delivery."""
email_permission = "netbox_slm.manage_softwarelicense_email_notifications"
def dispatch(self, request, *args, **kwargs):
form_attribute = "model_form" if hasattr(self, "model_form") else "form"
base_form = getattr(self, form_attribute)
can_manage_email = request.user.has_perm(self.email_permission)
class PermissionAwareForm(base_form):
def __init__(self, *form_args, **form_kwargs):
super().__init__(*form_args, **form_kwargs)
if not can_manage_email and "email_notifications" in self.fields:
field = self.fields["email_notifications"]
field.disabled = True
field.help_text = _(
"Only administrators or explicitly authorized users can change this setting."
)
setattr(self, form_attribute, PermissionAwareForm)
return super().dispatch(request, *args, **kwargs)
@register_model_view(SoftwareLicense, "list", path="", detail=False)
class SoftwareLicenseListView(generic.ObjectListView):
queryset = SoftwareLicense.objects.all()
@@ -19,7 +44,7 @@ class SoftwareLicenseView(generic.ObjectView):
@register_model_view(SoftwareLicense, "add", detail=False)
@register_model_view(SoftwareLicense, "edit")
class SoftwareLicenseEditView(generic.ObjectEditView):
class SoftwareLicenseEditView(EmailNotificationPermissionMixin, generic.ObjectEditView):
queryset = SoftwareLicense.objects.all()
form = forms.SoftwareLicenseForm
@@ -30,13 +55,13 @@ class SoftwareLicenseDeleteView(generic.ObjectDeleteView):
@register_model_view(SoftwareLicense, "bulk_import", detail=False)
class SoftwareLicenseBulkImportView(generic.BulkImportView):
class SoftwareLicenseBulkImportView(EmailNotificationPermissionMixin, generic.BulkImportView):
queryset = SoftwareLicense.objects.all()
model_form = forms.SoftwareLicenseBulkImportForm
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
class SoftwareLicenseBulkEditView(generic.BulkEditView):
class SoftwareLicenseBulkEditView(EmailNotificationPermissionMixin, generic.BulkEditView):
queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet
table = tables.SoftwareLicenseTable