From 294e20b89165884c4c344ae215baf1714d0ca894 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 29 Jul 2026 11:01:00 +0200 Subject: [PATCH] feat: add license expiration notifications --- CHANGELOG.md | 4 + README.md | 23 ++++- netbox_slm/__init__.py | 16 +++- netbox_slm/api/serializers.py | 20 ++++ netbox_slm/events.py | 1 + netbox_slm/filtersets.py | 2 +- netbox_slm/forms/software_license.py | 36 +++++++ netbox_slm/jobs.py | 90 ++++++++++++++++++ netbox_slm/locale/de/LC_MESSAGES/django.mo | Bin 6282 -> 8144 bytes netbox_slm/locale/de/LC_MESSAGES/django.po | 72 ++++++++++++++ .../0013_license_expiration_notifications.py | 77 +++++++++++++++ netbox_slm/models.py | 78 +++++++++++++++ netbox_slm/navigation.py | 2 +- netbox_slm/tables.py | 3 + .../templates/netbox_slm/softwarelicense.html | 38 ++++++++ netbox_slm/tests/test_models.py | 59 +++++++++++- 16 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 netbox_slm/events.py create mode 100644 netbox_slm/jobs.py create mode 100644 netbox_slm/migrations/0013_license_expiration_notifications.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed91e2..f936636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2481d4b..dc8d615 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_slm/__init__.py b/netbox_slm/__init__.py index b498ee9..e0530f8 100644 --- a/netbox_slm/__init__.py +++ b/netbox_slm/__init__.py @@ -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 diff --git a/netbox_slm/api/serializers.py b/netbox_slm/api/serializers.py index e6e88d9..1327ef7 100644 --- a/netbox_slm/api/serializers.py +++ b/netbox_slm/api/serializers.py @@ -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 diff --git a/netbox_slm/events.py b/netbox_slm/events.py new file mode 100644 index 0000000..a0886e9 --- /dev/null +++ b/netbox_slm/events.py @@ -0,0 +1 @@ +LICENSE_EXPIRING = "netbox_slm_license_expiring" diff --git a/netbox_slm/filtersets.py b/netbox_slm/filtersets.py index ea9a8f3..e9d7038 100644 --- a/netbox_slm/filtersets.py +++ b/netbox_slm/filtersets.py @@ -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.""" diff --git a/netbox_slm/forms/software_license.py b/netbox_slm/forms/software_license.py index e129473..b56aaf2 100644 --- a/netbox_slm/forms/software_license.py +++ b/netbox_slm/forms/software_license.py @@ -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(), diff --git a/netbox_slm/jobs.py b/netbox_slm/jobs.py new file mode 100644 index 0000000..d28793c --- /dev/null +++ b/netbox_slm/jobs.py @@ -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}") diff --git a/netbox_slm/locale/de/LC_MESSAGES/django.mo b/netbox_slm/locale/de/LC_MESSAGES/django.mo index 6e8442e2f24abd7a1ceaa015d331fab67f95875b..ac4b17ce4428f94b23fa5da6bb457f6e377dad16 100644 GIT binary patch literal 8144 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 diff --git a/netbox_slm/locale/de/LC_MESSAGES/django.po b/netbox_slm/locale/de/LC_MESSAGES/django.po index 4662037..d331a20 100644 --- a/netbox_slm/locale/de/LC_MESSAGES/django.po +++ b/netbox_slm/locale/de/LC_MESSAGES/django.po @@ -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." diff --git a/netbox_slm/migrations/0013_license_expiration_notifications.py b/netbox_slm/migrations/0013_license_expiration_notifications.py new file mode 100644 index 0000000..bd1d0f2 --- /dev/null +++ b/netbox_slm/migrations/0013_license_expiration_notifications.py @@ -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", + ), + ), + ] diff --git a/netbox_slm/models.py b/netbox_slm/models.py index fce3596..5d1b2b0 100644 --- a/netbox_slm/models.py +++ b/netbox_slm/models.py @@ -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", + ) + ] diff --git a/netbox_slm/navigation.py b/netbox_slm/navigation.py index 4e29655..5c137f9 100644 --- a/netbox_slm/navigation.py +++ b/netbox_slm/navigation.py @@ -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", ) diff --git a/netbox_slm/tables.py b/netbox_slm/tables.py index 0048946..4675f55 100644 --- a/netbox_slm/tables.py +++ b/netbox_slm/tables.py @@ -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", ) diff --git a/netbox_slm/templates/netbox_slm/softwarelicense.html b/netbox_slm/templates/netbox_slm/softwarelicense.html index b4340d3..10aeece 100644 --- a/netbox_slm/templates/netbox_slm/softwarelicense.html +++ b/netbox_slm/templates/netbox_slm/softwarelicense.html @@ -111,6 +111,44 @@ {% endif %} + + {% trans "Expiration notifications" %} + {% if object.expiration_notifications_enabled %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} + + + {% trans "Default notification intervals" %} + +{% if object.default_notification_intervals %} + {% trans "One month and one week before expiration" %} +{% else %} + {% trans "Disabled" %} +{% endif %} + + + + {% trans "Custom notification interval" %} + +{% if object.custom_notification_interval %} + {{ object.custom_notification_interval }} {{ object.get_custom_notification_interval_unit_display }} +{% else %} + {{ None|placeholder }} +{% endif %} + + + + {% trans "Email notifications" %} + {% if object.email_notifications %}{% trans "Enabled" %}{% else %}{% trans "Disabled" %}{% endif %} + + + {% trans "Notification recipients" %} + +{% 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 %} + + {% include 'inc/panels/custom_fields.html' %} diff --git a/netbox_slm/tests/test_models.py b/netbox_slm/tests/test_models.py index 31d816c..fb74949 100644 --- a/netbox_slm/tests/test_models.py +++ b/netbox_slm/tests/test_models.py @@ -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"))