import calendar from datetime import timedelta from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from license_expression import Licensing, get_spdx_licensing from netbox.models import NetBoxModel from utilities.querysets import RestrictedQuerySet from utilities.validators import EnhancedURLValidator spdx_licensing: Licensing = get_spdx_licensing() class LaxURLField(models.URLField): """ NetBox Custom Field approach, based on utilities.forms.fields.LaxURLField Overriding default_validators is needed, as they are always added """ default_validators = [EnhancedURLValidator()] class SoftwareProduct(NetBoxModel): name = models.CharField(_("name"), max_length=128) comments = models.TextField(_("comments"), blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True) manufacturer = models.ForeignKey( to="dcim.Manufacturer", verbose_name=_("manufacturer"), on_delete=models.PROTECT, null=True, blank=True ) objects = RestrictedQuerySet.as_manager() class Meta: verbose_name = _("software product") verbose_name_plural = _("software products") def __str__(self): return self.name def get_absolute_url(self): return reverse("plugins:netbox_slm:softwareproduct", kwargs={"pk": self.pk}) def get_installation_count(self): count = SoftwareProductInstallation.objects.filter(software_product_id=self.pk).count() search_target = reverse( "plugins:netbox_slm:softwareproductinstallation_list", query={"software_product_id": self.pk} ) return format_html("{}", search_target, count) if count else "0" class SoftwareReleaseTypes(models.TextChoices): ALPHA = "A", _("Alpha") BETA = "B", _("Beta") RELEASE_CANDIDATE = "RC", _("Release candidate") STABLE = "S", _("Stable release") class RenewalIntervalUnits(models.TextChoices): DAYS = "days", _("Days") MONTHS = "months", _("Months") YEARS = "years", _("Years") class NotificationIntervalUnits(models.TextChoices): DAYS = "days", _("Days") WEEKS = "weeks", _("Weeks") MONTHS = "months", _("Months") class SoftwareProductVersion(NetBoxModel): name = models.CharField(_("name"), max_length=64) comments = models.TextField(_("comments"), blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True) release_date = models.DateField(_("release date"), null=True, blank=True) documentation_url = LaxURLField(_("documentation URL"), max_length=1024, null=True, blank=True) end_of_support = models.DateField(_("end of support"), null=True, blank=True) filename = models.CharField(_("filename"), max_length=64, null=True, blank=True) file_checksum = models.CharField(_("file checksum"), max_length=128, null=True, blank=True) file_link = LaxURLField(_("file link"), max_length=1024, null=True, blank=True) release_type = models.CharField( _("release type"), max_length=3, choices=SoftwareReleaseTypes.choices, default=SoftwareReleaseTypes.STABLE, ) software_product = models.ForeignKey( to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT, ) objects = RestrictedQuerySet.as_manager() class Meta: verbose_name = _("software product version") verbose_name_plural = _("software product versions") def __str__(self): return self.name def get_absolute_url(self): return reverse("plugins:netbox_slm:softwareproductversion", kwargs={"pk": self.pk}) def get_installation_count(self): count = SoftwareProductInstallation.objects.filter(version_id=self.pk).count() search_target = reverse("plugins:netbox_slm:softwareproductinstallation_list", query={"version_id": self.pk}) return format_html("{}", search_target, count) if count else "0" class SoftwareProductInstallation(NetBoxModel): comments = models.TextField(_("comments"), blank=True) device = models.ForeignKey( to="dcim.Device", verbose_name=_("device"), on_delete=models.PROTECT, null=True, blank=True ) virtualmachine = models.ForeignKey( to="virtualization.VirtualMachine", verbose_name=_("virtual machine"), on_delete=models.PROTECT, null=True, blank=True, ) cluster = models.ForeignKey( to="virtualization.Cluster", verbose_name=_("cluster"), on_delete=models.PROTECT, null=True, blank=True ) software_product = models.ForeignKey( to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT ) version = models.ForeignKey( to="netbox_slm.SoftwareProductVersion", verbose_name=_("version"), on_delete=models.PROTECT ) objects = RestrictedQuerySet.as_manager() def __str__(self): return f"{self.pk} ({self.platform})" class Meta: verbose_name = _("software product installation") verbose_name_plural = _("software product installations") constraints = [ models.CheckConstraint( name="%(app_label)s_%(class)s_platform", condition=( models.Q(device__isnull=False, virtualmachine__isnull=True, cluster__isnull=True) | models.Q(device__isnull=True, virtualmachine__isnull=False, cluster__isnull=True) | models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False) ), violation_error_message=_( "Select exactly one platform destination: device, virtual machine, or cluster." ), ) ] def get_absolute_url(self): return reverse("plugins:netbox_slm:softwareproductinstallation", kwargs={"pk": self.pk}) @property def platform(self): return self.device or self.virtualmachine or self.cluster def render_type(self): if self.device: return "device" if self.virtualmachine: return "virtualmachine" return "cluster" def spdx_license_names(): names = [(item[0], item[0]) for item in spdx_licensing.known_symbols.items() if not item[1].is_exception] names.sort() names.insert(0, ("", "---------")) # set default value return names def validate_spdx_expression(value): expression_info = spdx_licensing.validate(value) if expression_info.errors: raise ValidationError(_("%(value)s is not a known SPDX license expression") % {"value": value}) class SoftwareLicense(NetBoxModel): name = models.CharField(_("name"), max_length=128) comments = models.TextField(_("comments"), blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True) type = models.CharField(_("type"), max_length=128, blank=True) spdx_expression = models.CharField( _("SPDX expression"), max_length=64, null=True, blank=True, validators=[validate_spdx_expression] ) stored_location = models.CharField(_("stored location"), max_length=255, null=True, blank=True) stored_location_url = LaxURLField(_("stored location URL"), max_length=1024, null=True, blank=True) start_date = models.DateField(_("start date"), null=True, blank=True) expiration_date = models.DateField(_("expiration date"), null=True, blank=True) support = models.BooleanField(_("support"), default=None, null=True, blank=True) license_amount = models.PositiveIntegerField(_("license amount"), default=None, null=True, blank=True) lifetime = models.BooleanField(_("lifetime license"), default=False) renewal_interval = models.PositiveIntegerField( _("renewal interval"), null=True, blank=True, validators=[MinValueValidator(1)] ) renewal_interval_unit = models.CharField( _("renewal interval unit"), max_length=8, choices=RenewalIntervalUnits.choices, blank=True ) license_file = models.FileField(_("license file"), upload_to="netbox_slm/licenses/", null=True, blank=True) license_key = models.TextField(_("license key"), blank=True) provider_portal_url = LaxURLField(_("provider portal URL"), max_length=1024, null=True, blank=True) expiration_notifications_enabled = models.BooleanField(_("expiration notifications"), default=True) default_notification_intervals = models.BooleanField(_("default notification intervals"), default=True) custom_notification_interval = models.PositiveIntegerField( _("custom notification interval"), null=True, blank=True, validators=[MinValueValidator(1)] ) custom_notification_interval_unit = models.CharField( _("custom notification interval unit"), max_length=8, choices=NotificationIntervalUnits.choices, blank=True, ) email_notifications = models.BooleanField(_("email notifications"), default=False) software_product = models.ForeignKey( to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT ) version = models.ForeignKey( to="netbox_slm.SoftwareProductVersion", verbose_name=_("version"), on_delete=models.PROTECT, null=True, blank=True, ) installation = models.ForeignKey( to="netbox_slm.SoftwareProductInstallation", verbose_name=_("installation"), on_delete=models.SET_NULL, null=True, blank=True, ) tenant_group = models.ForeignKey( to="tenancy.TenantGroup", verbose_name=_("tenant group"), on_delete=models.PROTECT, null=True, blank=True, related_name="software_licenses", ) tenant = models.ForeignKey( to="tenancy.Tenant", verbose_name=_("tenant"), on_delete=models.PROTECT, null=True, blank=True, related_name="software_licenses", ) objects = RestrictedQuerySet.as_manager() class Meta: verbose_name = _("software license") verbose_name_plural = _("software licenses") permissions = [ ("manage_softwarelicense_email_notifications", _("Can manage license email notifications")), ] def __str__(self): return self.name def get_absolute_url(self): return reverse("plugins:netbox_slm:softwarelicense", kwargs={"pk": self.pk}) def clean(self): super().clean() if self.tenant_group and self.tenant and self.tenant.group_id != self.tenant_group_id: raise ValidationError( {"tenant": _("The selected tenant does not belong to the selected tenant group.")} ) if bool(self.renewal_interval) != bool(self.renewal_interval_unit): raise ValidationError( {"renewal_interval": _("Renewal interval and unit must be specified together.")} ) if self.lifetime and (self.renewal_interval or self.renewal_interval_unit or self.expiration_date): raise ValidationError( {"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")} ) if bool(self.custom_notification_interval) != bool(self.custom_notification_interval_unit): raise ValidationError( { "custom_notification_interval": _( "Custom notification interval and unit must be specified together." ) } ) @staticmethod def _subtract_months(value, months): month_index = value.year * 12 + value.month - 1 - months year, zero_based_month = divmod(month_index, 12) month = zero_based_month + 1 day = min(value.day, calendar.monthrange(year, month)[1]) return value.replace(year=year, month=month, day=day) def get_notification_dates(self): """Return unique reminder dates for the current expiration date.""" if not self.expiration_notifications_enabled or self.lifetime or not self.expiration_date: return [] dates = set() if self.default_notification_intervals: dates.add(self._subtract_months(self.expiration_date, 1)) dates.add(self.expiration_date - timedelta(weeks=1)) if self.custom_notification_interval and self.custom_notification_interval_unit: amount = self.custom_notification_interval if self.custom_notification_interval_unit == NotificationIntervalUnits.MONTHS: dates.add(self._subtract_months(self.expiration_date, amount)) elif self.custom_notification_interval_unit == NotificationIntervalUnits.WEEKS: dates.add(self.expiration_date - timedelta(weeks=amount)) else: dates.add(self.expiration_date - timedelta(days=amount)) return sorted(dates) @property def stored_location_txt(self): if self.stored_location_url and not self.stored_location: return _("Link") return 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", ) ]