356 lines
14 KiB
Python
356 lines
14 KiB
Python
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("<a href='{}'>{}</a>", 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("<a href='{}'>{}</a>", 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",
|
|
)
|
|
]
|