6 Commits
18 changed files with 603 additions and 11 deletions
+9
View File
@@ -6,6 +6,9 @@
* Make the required single platform destination explicit when creating an installation * 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 * 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 ### Added
@@ -13,6 +16,9 @@
* German translation for navigation, forms, tables, detail views, choices, and validation messages * German translation for navigation, forms, tables, detail views, choices, and validation messages
* Optional lifetime or recurring renewal interval for licenses * Optional lifetime or recurring renewal interval for licenses
* Optional license file, license key, and provider portal URL * 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 ### Changed
@@ -21,6 +27,9 @@
* Render tenant group and tenant in a dedicated license form section * Render tenant group and tenant in a dedicated license form section
* Bump the package version so Git/Pip upgrades replace older installations * Bump the package version so Git/Pip upgrades replace older installations
* Make the license type field optional * 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 ## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
+27 -1
View File
@@ -16,6 +16,9 @@ Benutzer ausgewählten Sprache.
- Softwarelizenzen einschließlich Laufzeit, Umfang und Ablageort verwalten - Softwarelizenzen einschließlich Laufzeit, Umfang und Ablageort verwalten
- Lifetime-Lizenzen oder Erneuerungsintervalle in Tagen, Monaten oder Jahren erfassen - Lifetime-Lizenzen oder Erneuerungsintervalle in Tagen, Monaten oder Jahren erfassen
- Lizenzdateien, Lizenzschlüssel und Links zum Anbieterportal hinterlegen - 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 - Lizenzen Mandantengruppen und Mandanten zuordnen
- Bedienung über die NetBox-Oberfläche und REST-API - Bedienung über die NetBox-Oberfläche und REST-API
- Filter, Bulk-Import und Bulk-Bearbeitung - Filter, Bulk-Import und Bulk-Bearbeitung
@@ -63,7 +66,30 @@ Die installierte Version kann anschließend geprüft werden:
/opt/netbox/venv/bin/pip show netbox-slm /opt/netbox/venv/bin/pip show netbox-slm
``` ```
Für diese Variante muss dort mindestens Version `1.11.2` stehen. 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 ### 3. Plugin in NetBox aktivieren
+14 -4
View File
@@ -15,16 +15,16 @@ limitations under the License.
""" """
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
__version__ = "1.11.2" __version__ = "1.13.0"
class SLMConfig(PluginConfig): class SLMConfig(PluginConfig):
name = "netbox_slm" name = "netbox_slm"
verbose_name = _("Software Lifecycle Management") verbose_name = _("Licences")
version = __version__ version = __version__
description = _("Software Lifecycle Management NetBox Plugin.") description = _("Licence Management NetBox Plugin.")
author = "ICTU" author = "ICTU"
author_email = "open-source-projects@ictu.nl" author_email = "open-source-projects@ictu.nl"
base_url = "slm" base_url = "slm"
@@ -36,5 +36,15 @@ class SLMConfig(PluginConfig):
"link_virtualmachine_installations": "right", "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 config = SLMConfig
+30
View File
@@ -32,6 +32,11 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
"license_file", "license_file",
"license_key", "license_key",
"provider_portal_url", "provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product", "software_product",
"version", "version",
"installation", "installation",
@@ -49,6 +54,16 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
return f"{obj}" return f"{obj}"
def validate(self, attrs): 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_group = attrs.get("tenant_group", getattr(self.instance, "tenant_group", None))
tenant = attrs.get("tenant", getattr(self.instance, "tenant", None)) tenant = attrs.get("tenant", getattr(self.instance, "tenant", None))
if tenant_group and tenant and tenant.group_id != tenant_group.pk: if tenant_group and tenant and tenant.group_id != tenant_group.pk:
@@ -69,6 +84,21 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
raise serializers.ValidationError( raise serializers.ValidationError(
{"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")} {"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 return attrs
+1
View File
@@ -0,0 +1 @@
LICENSE_EXPIRING = "netbox_slm_license_expiring"
+1 -1
View File
@@ -132,7 +132,7 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = SoftwareLicense model = SoftwareLicense
fields = ("support", "lifetime") fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
def search(self, queryset, name, value): def search(self, queryset, name, value):
"""Perform the filtered search.""" """Perform the filtered search."""
+36
View File
@@ -9,6 +9,7 @@ from netbox_slm.models import (
SoftwareProductInstallation, SoftwareProductInstallation,
SoftwareLicense, SoftwareLicense,
RenewalIntervalUnits, RenewalIntervalUnits,
NotificationIntervalUnits,
spdx_license_names, spdx_license_names,
) )
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@@ -51,6 +52,14 @@ class SoftwareLicenseForm(NetBoxModelForm):
name=_("License Details"), name=_("License Details"),
), ),
FieldSet("license_file", "license_key", "provider_portal_url", name=_("License Credentials")), 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")), FieldSet("tags", name=_("Tags")),
) )
@@ -61,6 +70,7 @@ class SoftwareLicenseForm(NetBoxModelForm):
renewal_interval = IntegerField(required=False, min_value=1) renewal_interval = IntegerField(required=False, min_value=1)
license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3})) license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3}))
provider_portal_url = LaxURLField(required=False) provider_portal_url = LaxURLField(required=False)
custom_notification_interval = IntegerField(required=False, min_value=1)
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
@@ -113,6 +123,11 @@ class SoftwareLicenseForm(NetBoxModelForm):
"license_file", "license_file",
"license_key", "license_key",
"provider_portal_url", "provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version", "version",
"installation", "installation",
"tenant_group", "tenant_group",
@@ -136,6 +151,8 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
"lifetime", "lifetime",
"renewal_interval", "renewal_interval",
"renewal_interval_unit", "renewal_interval_unit",
"expiration_notifications_enabled",
"email_notifications",
"software_product_id", "software_product_id",
"version_id", "version_id",
"installation_id", "installation_id",
@@ -156,6 +173,8 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
lifetime = 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 = IntegerField(required=False, min_value=1)
renewal_interval_unit = ChoiceField(required=False, choices=RenewalIntervalUnits.choices) 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( software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
@@ -208,6 +227,11 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
"license_amount", "license_amount",
"license_key", "license_key",
"provider_portal_url", "provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version", "version",
"installation", "installation",
"tenant_group", "tenant_group",
@@ -233,6 +257,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"license_amount", "license_amount",
"license_key", "license_key",
"provider_portal_url", "provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product", "software_product",
"version", "version",
"installation", "installation",
@@ -253,6 +282,8 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"license_amount", "license_amount",
"license_key", "license_key",
"provider_portal_url", "provider_portal_url",
"custom_notification_interval",
"custom_notification_interval_unit",
"version", "version",
"installation", "installation",
"tenant_group", "tenant_group",
@@ -275,6 +306,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
license_amount = IntegerField(required=False, min_value=0) license_amount = IntegerField(required=False, min_value=0)
license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3})) license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3}))
provider_portal_url = LaxURLField(required=False) 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( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
+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.
@@ -336,3 +336,84 @@ msgstr "Leer lassen, wenn ein anderes Plattformziel ausgewählt wurde."
msgid "Select exactly one platform destination: device, virtual machine, or cluster." msgid "Select exactly one platform destination: device, virtual machine, or cluster."
msgstr "Wähle genau ein Plattformziel aus: Gerät, virtuelle Maschine oder 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,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",
},
),
]
+81
View File
@@ -1,3 +1,7 @@
import calendar
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
@@ -64,6 +68,12 @@ class RenewalIntervalUnits(models.TextChoices):
YEARS = "years", _("Years") YEARS = "years", _("Years")
class NotificationIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
WEEKS = "weeks", _("Weeks")
MONTHS = "months", _("Months")
class SoftwareProductVersion(NetBoxModel): class SoftwareProductVersion(NetBoxModel):
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
comments = models.TextField(_("comments"), blank=True) comments = models.TextField(_("comments"), blank=True)
@@ -205,6 +215,18 @@ class SoftwareLicense(NetBoxModel):
license_file = models.FileField(_("license file"), upload_to="netbox_slm/licenses/", null=True, 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) license_key = models.TextField(_("license key"), blank=True)
provider_portal_url = LaxURLField(_("provider portal URL"), max_length=1024, null=True, 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( software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT
@@ -245,6 +267,9 @@ class SoftwareLicense(NetBoxModel):
class Meta: class Meta:
verbose_name = _("software license") verbose_name = _("software license")
verbose_name_plural = _("software licenses") verbose_name_plural = _("software licenses")
permissions = [
("manage_softwarelicense_email_notifications", _("Can manage license email notifications")),
]
def __str__(self): def __str__(self):
return self.name return self.name
@@ -266,9 +291,65 @@ class SoftwareLicense(NetBoxModel):
raise ValidationError( raise ValidationError(
{"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")} {"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 @property
def stored_location_txt(self): def stored_location_txt(self):
if self.stored_location_url and not self.stored_location: if self.stored_location_url and not self.stored_location:
return _("Link") return _("Link")
return self.stored_location 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",
)
]
+1 -1
View File
@@ -83,7 +83,7 @@ slm_items = (
if get_plugin_config("netbox_slm", "top_level_menu"): if get_plugin_config("netbox_slm", "top_level_menu"):
menu = PluginMenu( menu = PluginMenu(
label=_("Software Lifecycle"), label=_("Licences"),
groups=((SLMConfig.verbose_name, slm_items),), groups=((SLMConfig.verbose_name, slm_items),),
icon_class="mdi mdi-content-save", icon_class="mdi mdi-content-save",
) )
+3
View File
@@ -174,6 +174,8 @@ class SoftwareLicenseTable(NetBoxTable):
"support", "support",
"license_amount", "license_amount",
"provider_portal_url", "provider_portal_url",
"expiration_notifications_enabled",
"email_notifications",
"tags", "tags",
) )
default_columns = ( default_columns = (
@@ -187,6 +189,7 @@ class SoftwareLicenseTable(NetBoxTable):
"tenant", "tenant",
"expiration_date", "expiration_date",
"lifetime", "lifetime",
"expiration_notifications_enabled",
"tags", "tags",
) )
@@ -111,6 +111,44 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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> </table>
</div> </div>
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
+62 -1
View File
@@ -1,7 +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.core.exceptions import ValidationError
from django.test import override_settings
from django.utils import timezone
from django.utils.translation import gettext, override from django.utils.translation import gettext, override
from extras.models import Notification, Subscription
from netbox_slm.forms import SoftwareLicenseForm, SoftwareProductInstallationForm from netbox_slm.forms import SoftwareLicenseForm, SoftwareProductInstallationForm
from netbox_slm.models import RenewalIntervalUnits, SoftwareLicense from netbox_slm.jobs import LicenseExpirationNotificationJob
from netbox_slm.models import (
NotificationIntervalUnits,
RenewalIntervalUnits,
SoftwareLicense,
SoftwareLicenseNotificationLog,
)
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from .base import SlmBaseTestCase from .base import SlmBaseTestCase
@@ -134,6 +149,52 @@ class ModelTestCase(SlmBaseTestCase):
with self.assertRaisesMessage(ValidationError, "cannot have a renewal interval or expiration date"): with self.assertRaisesMessage(ValidationError, "cannot have a renewal interval or expiration date"):
license.full_clean() 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): def test_german_translation(self):
with override("de"): with override("de"):
self.assertEqual("Softwarelizenz", gettext("Software License")) self.assertEqual("Softwarelizenz", gettext("Software License"))
+28 -3
View File
@@ -1,9 +1,34 @@
from netbox.views import generic from netbox.views import generic
from django.utils.translation import gettext_lazy as _
from netbox_slm import filtersets, forms, tables from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareLicense from netbox_slm.models import SoftwareLicense
from utilities.views import register_model_view 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) @register_model_view(SoftwareLicense, "list", path="", detail=False)
class SoftwareLicenseListView(generic.ObjectListView): class SoftwareLicenseListView(generic.ObjectListView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
@@ -19,7 +44,7 @@ class SoftwareLicenseView(generic.ObjectView):
@register_model_view(SoftwareLicense, "add", detail=False) @register_model_view(SoftwareLicense, "add", detail=False)
@register_model_view(SoftwareLicense, "edit") @register_model_view(SoftwareLicense, "edit")
class SoftwareLicenseEditView(generic.ObjectEditView): class SoftwareLicenseEditView(EmailNotificationPermissionMixin, generic.ObjectEditView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
form = forms.SoftwareLicenseForm form = forms.SoftwareLicenseForm
@@ -30,13 +55,13 @@ class SoftwareLicenseDeleteView(generic.ObjectDeleteView):
@register_model_view(SoftwareLicense, "bulk_import", detail=False) @register_model_view(SoftwareLicense, "bulk_import", detail=False)
class SoftwareLicenseBulkImportView(generic.BulkImportView): class SoftwareLicenseBulkImportView(EmailNotificationPermissionMixin, generic.BulkImportView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
model_form = forms.SoftwareLicenseBulkImportForm model_form = forms.SoftwareLicenseBulkImportForm
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False) @register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
class SoftwareLicenseBulkEditView(generic.BulkEditView): class SoftwareLicenseBulkEditView(EmailNotificationPermissionMixin, generic.BulkEditView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet filterset = filtersets.SoftwareLicenseFilterSet
table = tables.SoftwareLicenseTable table = tables.SoftwareLicenseTable