feat: add license expiration notifications
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
LICENSE_EXPIRING = "netbox_slm_license_expiring"
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user