feat: add license expiration notifications

This commit is contained in:
2026-07-29 11:01:00 +02:00
parent 9f3bddfbb1
commit 294e20b891
16 changed files with 514 additions and 7 deletions
+4
View File
@@ -13,6 +13,9 @@
* 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
@@ -21,6 +24,7 @@
* 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
## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
+22 -1
View File
@@ -16,6 +16,9 @@ Benutzer ausgewählten Sprache.
- 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
@@ -63,7 +66,25 @@ Die installierte Version kann anschließend geprüft werden:
/opt/netbox/venv/bin/pip show netbox-slm
```
Für diese Variante muss dort mindestens Version `1.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 Prüfung läuft einmal täglich als NetBox-Systemjob. Der NetBox-RQ-Worker muss daher mit Scheduler-Unterstützung
laufen; dies ist bei der regulären NetBox-Installation mit `rqworker` standardmäßig der Fall. Das interne
Versandprotokoll verhindert doppelte Nachrichten.
### 3. Plugin in NetBox aktivieren
+13 -3
View File
@@ -17,14 +17,14 @@ limitations under the License.
from netbox.plugins import PluginConfig
from django.utils.translation import gettext_lazy as _
__version__ = "1.11.2"
__version__ = "1.12.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"
@@ -36,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, _("License expiring"), kind=EVENT_TYPE_KIND_WARNING).register()
from netbox_slm import jobs # noqa: F401
config = SLMConfig
+20
View File
@@ -32,6 +32,11 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
"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",
@@ -69,6 +74,21 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
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"
+1 -1
View File
@@ -132,7 +132,7 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
class Meta:
model = SoftwareLicense
fields = ("support", "lifetime")
fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
def search(self, queryset, name, value):
"""Perform the filtered search."""
+36
View File
@@ -9,6 +9,7 @@ from netbox_slm.models import (
SoftwareProductInstallation,
SoftwareLicense,
RenewalIntervalUnits,
NotificationIntervalUnits,
spdx_license_names,
)
from tenancy.models import Tenant, TenantGroup
@@ -51,6 +52,14 @@ class SoftwareLicenseForm(NetBoxModelForm):
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")),
)
@@ -61,6 +70,7 @@ class SoftwareLicenseForm(NetBoxModelForm):
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(),
@@ -113,6 +123,11 @@ class SoftwareLicenseForm(NetBoxModelForm):
"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",
@@ -136,6 +151,8 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"expiration_notifications_enabled",
"email_notifications",
"software_product_id",
"version_id",
"installation_id",
@@ -156,6 +173,8 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
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(),
@@ -208,6 +227,11 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
"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",
@@ -233,6 +257,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"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",
@@ -253,6 +282,8 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"license_amount",
"license_key",
"provider_portal_url",
"custom_notification_interval",
"custom_notification_interval_unit",
"version",
"installation",
"tenant_group",
@@ -275,6 +306,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
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(),
+90
View File
@@ -0,0 +1,90 @@
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,
)
now = timezone.now()
if log.netbox_sent_at is None:
object_type = ContentType.objects.get_for_model(license)
Notification.objects.update_or_create(
object_type=object_type,
object_id=license.pk,
user=user,
defaults={
"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 and log.email_sent_at is None:
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:
send_mail(subject, body, settings.DEFAULT_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,75 @@ 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."
@@ -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",
),
),
]
+78
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.validators import MinValueValidator
from django.db import models
@@ -64,6 +68,12 @@ class RenewalIntervalUnits(models.TextChoices):
YEARS = "years", _("Years")
class NotificationIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
WEEKS = "weeks", _("Weeks")
MONTHS = "months", _("Months")
class SoftwareProductVersion(NetBoxModel):
name = models.CharField(_("name"), max_length=64)
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_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", verbose_name=_("software product"), on_delete=models.PROTECT
@@ -266,9 +288,65 @@ class SoftwareLicense(NetBoxModel):
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 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"):
menu = PluginMenu(
label=_("Software Lifecycle"),
label=_("Licences"),
groups=((SLMConfig.verbose_name, slm_items),),
icon_class="mdi mdi-content-save",
)
+3
View File
@@ -174,6 +174,8 @@ class SoftwareLicenseTable(NetBoxTable):
"support",
"license_amount",
"provider_portal_url",
"expiration_notifications_enabled",
"email_notifications",
"tags",
)
default_columns = (
@@ -187,6 +189,7 @@ class SoftwareLicenseTable(NetBoxTable):
"tenant",
"expiration_date",
"lifetime",
"expiration_notifications_enabled",
"tags",
)
@@ -111,6 +111,44 @@
{% 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' %}
+58 -1
View File
@@ -1,7 +1,21 @@
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.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.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 .base import SlmBaseTestCase
@@ -134,6 +148,49 @@ class ModelTestCase(SlmBaseTestCase):
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(),
)
@patch("netbox_slm.jobs.send_mail")
def test_expiration_job_notifies_subscribers_once(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()
self.assertEqual(1, Notification.objects.filter(user=user, object_id=license.pk).count())
self.assertEqual(1, SoftwareLicenseNotificationLog.objects.filter(user=user, license=license).count())
send_mail.assert_called_once()
def test_german_translation(self):
with override("de"):
self.assertEqual("Softwarelizenz", gettext("Software License"))