Compare commits
11
Commits
e1c817229b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d62f26b2 | ||
|
|
35ddffc903 | ||
|
|
919b2d01e5 | ||
|
|
157c891cde | ||
|
|
3599dc703e | ||
|
|
294e20b891 | ||
|
|
9f3bddfbb1 | ||
|
|
71a5db0ece | ||
|
|
adfc2ae72c | ||
|
|
5887129546 | ||
|
|
7ca670c717 |
@@ -2,14 +2,34 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Make the required single platform destination explicit when creating an installation
|
||||||
|
* Fix installation form submission on NetBox v4.6.5 when form mixins return no cleaned-data value
|
||||||
|
* Prevent the notification log creation flag from shadowing Django's translation function during email delivery
|
||||||
|
* Use NetBox's configured `EMAIL['FROM_EMAIL']` value as the SMTP sender instead of Django's localhost fallback
|
||||||
|
* Register the expiration event with a concrete string so NetBox 4.6 can render the notification dropdown
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* Optional tenant group and tenant assignments for software licenses
|
* Optional tenant group and tenant assignments for software licenses
|
||||||
|
* 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
|
### Changed
|
||||||
|
|
||||||
* Support NetBox v4.6.5
|
* Support NetBox v4.6.5
|
||||||
* Replace the Docker-based setup with Git/Pip installation documentation
|
* Replace the Docker-based setup with Git/Pip installation documentation
|
||||||
|
* 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
|
||||||
|
* Restrict SMTP notification activation to administrators and explicitly authorized users
|
||||||
|
* Repeat NetBox and optional email reminders on every daily job run from the first due interval through expiration
|
||||||
|
|
||||||
## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
|
## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,28 @@ Lizenzen können optional einer NetBox-Mandantengruppe und einem Mandanten zugeo
|
|||||||
|
|
||||||
Diese Variante ist für **NetBox 4.6.5** vorgesehen.
|
Diese Variante ist für **NetBox 4.6.5** vorgesehen.
|
||||||
|
|
||||||
|
Die Oberfläche ist auf Englisch und Deutsch verfügbar. Die Sprache richtet sich nach der in NetBox für den
|
||||||
|
Benutzer ausgewählten Sprache.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- Softwareprodukte und Hersteller verwalten
|
- Softwareprodukte und Hersteller verwalten
|
||||||
- Versionen, Release-Typen und Supportzeiträume erfassen
|
- Versionen, Release-Typen und Supportzeiträume erfassen
|
||||||
- Installationen Geräten, virtuellen Maschinen oder Clustern zuordnen
|
- Installationen Geräten, virtuellen Maschinen oder Clustern zuordnen
|
||||||
- Softwarelizenzen einschließlich Laufzeit, Umfang und Ablageort verwalten
|
- 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
|
- Lizenzen Mandantengruppen und Mandanten zuordnen
|
||||||
- Bedienung über die NetBox-Oberfläche und REST-API
|
- Bedienung über die NetBox-Oberfläche und REST-API
|
||||||
- Filter, Bulk-Import und Bulk-Bearbeitung
|
- Filter, Bulk-Import und Bulk-Bearbeitung
|
||||||
|
|
||||||
|
> **Hinweis:** Lizenzschlüssel werden in der NetBox-Datenbank und Lizenzdateien im konfigurierten NetBox-
|
||||||
|
> Medienverzeichnis gespeichert. Der Zugriff auf Datenbank, REST-API und Medienverzeichnis sollte entsprechend
|
||||||
|
> geschützt und gesichert werden.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
NetBox installiert lokale Erweiterungen aus `/opt/netbox/local_requirements.txt`. Die Git-URL muss dort dauerhaft
|
NetBox installiert lokale Erweiterungen aus `/opt/netbox/local_requirements.txt`. Die Git-URL muss dort dauerhaft
|
||||||
@@ -38,7 +50,8 @@ grep -qxF "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" /opt/netbox/local_
|
|||||||
Die Installation muss im Python-Virtual-Environment von NetBox erfolgen:
|
Die Installation muss im Python-Virtual-Environment von NetBox erfolgen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
|
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||||
|
"git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ können alle lokalen Anforderungen installiert werden:
|
Alternativ können alle lokalen Anforderungen installiert werden:
|
||||||
@@ -47,6 +60,37 @@ Alternativ können alle lokalen Anforderungen installiert werden:
|
|||||||
/opt/netbox/venv/bin/pip install -r /opt/netbox/local_requirements.txt
|
/opt/netbox/venv/bin/pip install -r /opt/netbox/local_requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die installierte Version kann anschließend geprüft werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/netbox/venv/bin/pip show netbox-slm
|
||||||
|
```
|
||||||
|
|
||||||
|
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 Aktivierung des E-Mail-Versands darf nur durch Administratoren oder Benutzer mit der Berechtigung
|
||||||
|
`netbox_slm.manage_softwarelicense_email_notifications` geändert werden. Die eigentlichen SMTP-Zugangsdaten werden
|
||||||
|
ausschließlich in der NetBox-Serverkonfiguration gepflegt und sind im Plugin nicht einsehbar.
|
||||||
|
|
||||||
|
Die Prüfung läuft einmal täglich als NetBox-Systemjob. Sobald der erste konfigurierte Erinnerungstermin erreicht ist,
|
||||||
|
werden die Abonnenten bei jedem Joblauf erneut informiert: immer über NetBox und bei aktivierter E-Mail-Option
|
||||||
|
zusätzlich per SMTP. Dies wird bis einschließlich des Ablaufdatums wiederholt. Der NetBox-RQ-Worker muss dafür mit
|
||||||
|
Scheduler-Unterstützung laufen; dies ist bei der regulären NetBox-Installation mit `rqworker` standardmäßig der Fall.
|
||||||
|
|
||||||
### 3. Plugin in NetBox aktivieren
|
### 3. Plugin in NetBox aktivieren
|
||||||
|
|
||||||
In `/opt/netbox/netbox/netbox/configuration.py` ergänzen:
|
In `/opt/netbox/netbox/netbox/configuration.py` ergänzen:
|
||||||
|
|||||||
+14
-3
@@ -15,15 +15,16 @@ limitations under the License.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from netbox.plugins import PluginConfig
|
from netbox.plugins import PluginConfig
|
||||||
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
|
|
||||||
__version__ = "1.9.0"
|
__version__ = "1.13.0"
|
||||||
|
|
||||||
|
|
||||||
class SLMConfig(PluginConfig):
|
class SLMConfig(PluginConfig):
|
||||||
name = "netbox_slm"
|
name = "netbox_slm"
|
||||||
verbose_name = "Software Lifecycle Management"
|
verbose_name = _("Licences")
|
||||||
version = __version__
|
version = __version__
|
||||||
description = "Software Lifecycle Management Netbox Plugin."
|
description = _("Licence Management NetBox Plugin.")
|
||||||
author = "ICTU"
|
author = "ICTU"
|
||||||
author_email = "open-source-projects@ictu.nl"
|
author_email = "open-source-projects@ictu.nl"
|
||||||
base_url = "slm"
|
base_url = "slm"
|
||||||
@@ -35,5 +36,15 @@ class SLMConfig(PluginConfig):
|
|||||||
"link_virtualmachine_installations": "right",
|
"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, gettext("License expiring"), kind=EVENT_TYPE_KIND_WARNING).register()
|
||||||
|
|
||||||
|
from netbox_slm import jobs # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
config = SLMConfig
|
config = SLMConfig
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
|
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.api.serializers import NetBoxModelSerializer
|
from netbox.api.serializers import NetBoxModelSerializer
|
||||||
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
|
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
|
||||||
@@ -23,8 +24,19 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
|
|||||||
"stored_location_url",
|
"stored_location_url",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"license_file",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"default_notification_intervals",
|
||||||
|
"custom_notification_interval",
|
||||||
|
"custom_notification_interval_unit",
|
||||||
|
"email_notifications",
|
||||||
"software_product",
|
"software_product",
|
||||||
"version",
|
"version",
|
||||||
"installation",
|
"installation",
|
||||||
@@ -42,11 +54,50 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
|
|||||||
return f"{obj}"
|
return f"{obj}"
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if (
|
||||||
|
"email_notifications" in attrs
|
||||||
|
and request is not None
|
||||||
|
and not request.user.has_perm("netbox_slm.manage_softwarelicense_email_notifications")
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"email_notifications": _("You do not have permission to change email notifications.")}
|
||||||
|
)
|
||||||
|
|
||||||
tenant_group = attrs.get("tenant_group", getattr(self.instance, "tenant_group", None))
|
tenant_group = attrs.get("tenant_group", getattr(self.instance, "tenant_group", None))
|
||||||
tenant = attrs.get("tenant", getattr(self.instance, "tenant", None))
|
tenant = attrs.get("tenant", getattr(self.instance, "tenant", None))
|
||||||
if tenant_group and tenant and tenant.group_id != tenant_group.pk:
|
if tenant_group and tenant and tenant.group_id != tenant_group.pk:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"tenant": "The selected tenant does not belong to the selected tenant group."}
|
{"tenant": _("The selected tenant does not belong to the selected tenant group.")}
|
||||||
|
)
|
||||||
|
lifetime = attrs.get("lifetime", getattr(self.instance, "lifetime", False))
|
||||||
|
renewal_interval = attrs.get("renewal_interval", getattr(self.instance, "renewal_interval", None))
|
||||||
|
renewal_interval_unit = attrs.get(
|
||||||
|
"renewal_interval_unit", getattr(self.instance, "renewal_interval_unit", "")
|
||||||
|
)
|
||||||
|
expiration_date = attrs.get("expiration_date", getattr(self.instance, "expiration_date", None))
|
||||||
|
if bool(renewal_interval) != bool(renewal_interval_unit):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"renewal_interval": _("Renewal interval and unit must be specified together.")}
|
||||||
|
)
|
||||||
|
if lifetime and (renewal_interval or renewal_interval_unit or expiration_date):
|
||||||
|
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
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
LICENSE_EXPIRING = "netbox_slm_license_expiring"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter
|
||||||
|
|
||||||
from dcim.models import Device, Manufacturer
|
from dcim.models import Device, Manufacturer
|
||||||
from netbox.filtersets import NetBoxModelFilterSet
|
from netbox.filtersets import NetBoxModelFilterSet
|
||||||
@@ -9,6 +10,7 @@ from netbox_slm.models import (
|
|||||||
SoftwareProductInstallation,
|
SoftwareProductInstallation,
|
||||||
SoftwareLicense,
|
SoftwareLicense,
|
||||||
SoftwareReleaseTypes,
|
SoftwareReleaseTypes,
|
||||||
|
RenewalIntervalUnits,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, VirtualMachine
|
from virtualization.models import Cluster, VirtualMachine
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@@ -55,7 +57,7 @@ class SoftwareProductVersionFilterSet(NetBoxModelFilterSet):
|
|||||||
|
|
||||||
software_product_id = ModelMultipleChoiceFilter(
|
software_product_id = ModelMultipleChoiceFilter(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -82,12 +84,12 @@ class SoftwareProductInstallationFilterSet(NetBoxModelFilterSet):
|
|||||||
device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all())
|
device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all())
|
||||||
virtualmachine_id = ModelMultipleChoiceFilter(
|
virtualmachine_id = ModelMultipleChoiceFilter(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
label="Virtual Machine",
|
label=_("Virtual Machine"),
|
||||||
)
|
)
|
||||||
cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all())
|
cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all())
|
||||||
software_product_id = ModelMultipleChoiceFilter(
|
software_product_id = ModelMultipleChoiceFilter(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
|
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
|
||||||
|
|
||||||
@@ -114,21 +116,23 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
|
|||||||
name = CharFilter(lookup_expr="icontains")
|
name = CharFilter(lookup_expr="icontains")
|
||||||
description = CharFilter(lookup_expr="icontains")
|
description = CharFilter(lookup_expr="icontains")
|
||||||
type = CharFilter(lookup_expr="icontains")
|
type = CharFilter(lookup_expr="icontains")
|
||||||
spdx_expression = CharFilter(lookup_expr="icontains", label="SPDX expression")
|
spdx_expression = CharFilter(lookup_expr="icontains", label=_("SPDX expression"))
|
||||||
stored_location = CharFilter(lookup_expr="icontains")
|
stored_location = CharFilter(lookup_expr="icontains")
|
||||||
|
renewal_interval = NumberFilter()
|
||||||
|
renewal_interval_unit = MultipleChoiceFilter(choices=RenewalIntervalUnits.choices)
|
||||||
|
|
||||||
software_product_id = ModelMultipleChoiceFilter(
|
software_product_id = ModelMultipleChoiceFilter(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
|
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
|
||||||
installation_id = ModelMultipleChoiceFilter(queryset=SoftwareProductInstallation.objects.all())
|
installation_id = ModelMultipleChoiceFilter(queryset=SoftwareProductInstallation.objects.all())
|
||||||
tenant_group_id = ModelMultipleChoiceFilter(queryset=TenantGroup.objects.all(), label="Tenant Group")
|
tenant_group_id = ModelMultipleChoiceFilter(queryset=TenantGroup.objects.all(), label=_("Tenant Group"))
|
||||||
tenant_id = ModelMultipleChoiceFilter(queryset=Tenant.objects.all(), label="Tenant")
|
tenant_id = ModelMultipleChoiceFilter(queryset=Tenant.objects.all(), label=_("Tenant"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SoftwareLicense
|
model = SoftwareLicense
|
||||||
fields = ("support",)
|
fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
"""Perform the filtered search."""
|
"""Perform the filtered search."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField
|
from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField, Textarea
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
||||||
from netbox_slm.models import (
|
from netbox_slm.models import (
|
||||||
@@ -7,6 +8,8 @@ from netbox_slm.models import (
|
|||||||
SoftwareProductVersion,
|
SoftwareProductVersion,
|
||||||
SoftwareProductInstallation,
|
SoftwareProductInstallation,
|
||||||
SoftwareLicense,
|
SoftwareLicense,
|
||||||
|
RenewalIntervalUnits,
|
||||||
|
NotificationIntervalUnits,
|
||||||
spdx_license_names,
|
spdx_license_names,
|
||||||
)
|
)
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@@ -24,16 +27,55 @@ from utilities.forms.widgets import APISelect, DatePicker
|
|||||||
|
|
||||||
class SoftwareLicenseForm(NetBoxModelForm):
|
class SoftwareLicenseForm(NetBoxModelForm):
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet(
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"software_product",
|
||||||
|
"version",
|
||||||
|
"installation",
|
||||||
|
name=_("Software License"),
|
||||||
|
),
|
||||||
|
FieldSet("tenant_group", "tenant", name=_("Tenancy")),
|
||||||
|
FieldSet(
|
||||||
|
"type",
|
||||||
|
"spdx_expression",
|
||||||
|
"stored_location",
|
||||||
|
"stored_location_url",
|
||||||
|
"start_date",
|
||||||
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
|
"support",
|
||||||
|
"license_amount",
|
||||||
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label="SPDX expression")
|
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
|
||||||
stored_location_url = LaxURLField(required=False)
|
stored_location_url = LaxURLField(required=False)
|
||||||
start_date = DateField(required=False, widget=DatePicker())
|
start_date = DateField(required=False, widget=DatePicker())
|
||||||
expiration_date = DateField(required=False, widget=DatePicker())
|
expiration_date = DateField(required=False, widget=DatePicker())
|
||||||
|
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(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=True,
|
required=True,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version = DynamicModelChoiceField(
|
version = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
@@ -52,13 +94,13 @@ class SoftwareLicenseForm(NetBoxModelForm):
|
|||||||
tenant_group = DynamicModelChoiceField(
|
tenant_group = DynamicModelChoiceField(
|
||||||
queryset=TenantGroup.objects.all(),
|
queryset=TenantGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Tenant Group",
|
label=_("Tenant Group"),
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={"group_id": "$tenant_group"},
|
query_params={"group_id": "$tenant_group"},
|
||||||
label="Tenant",
|
label=_("Tenant"),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -73,8 +115,19 @@ class SoftwareLicenseForm(NetBoxModelForm):
|
|||||||
"stored_location_url",
|
"stored_location_url",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"license_file",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"default_notification_intervals",
|
||||||
|
"custom_notification_interval",
|
||||||
|
"custom_notification_interval_unit",
|
||||||
|
"email_notifications",
|
||||||
"version",
|
"version",
|
||||||
"installation",
|
"installation",
|
||||||
"tenant_group",
|
"tenant_group",
|
||||||
@@ -95,6 +148,11 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
|
|||||||
"spdx_expression",
|
"spdx_expression",
|
||||||
"stored_location",
|
"stored_location",
|
||||||
"support",
|
"support",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"email_notifications",
|
||||||
"software_product_id",
|
"software_product_id",
|
||||||
"version_id",
|
"version_id",
|
||||||
"installation_id",
|
"installation_id",
|
||||||
@@ -109,40 +167,47 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
|
|||||||
name = CharField(required=False)
|
name = CharField(required=False)
|
||||||
description = CharField(required=False)
|
description = CharField(required=False)
|
||||||
type = CharField(required=False)
|
type = CharField(required=False)
|
||||||
spdx_expression = CharField(required=False, label="SPDX expression")
|
spdx_expression = CharField(required=False, label=_("SPDX expression"))
|
||||||
stored_location = CharField(required=False)
|
stored_location = CharField(required=False)
|
||||||
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
|
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
|
||||||
|
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(
|
software_product_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version_id = DynamicModelMultipleChoiceField(
|
version_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Version",
|
label=_("Version"),
|
||||||
)
|
)
|
||||||
installation_id = DynamicModelMultipleChoiceField(
|
installation_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProductInstallation.objects.all(),
|
queryset=SoftwareProductInstallation.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Installation",
|
label=_("Installation"),
|
||||||
)
|
)
|
||||||
tenant_group_id = DynamicModelMultipleChoiceField(
|
tenant_group_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=TenantGroup.objects.all(),
|
queryset=TenantGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Tenant Group",
|
label=_("Tenant Group"),
|
||||||
)
|
)
|
||||||
tenant_id = DynamicModelMultipleChoiceField(
|
tenant_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={"group_id": "$tenant_group_id"},
|
query_params={"group_id": "$tenant_group_id"},
|
||||||
label="Tenant",
|
label=_("Tenant"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
|
class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
|
||||||
support = NullBooleanField(required=False, help_text="Support (values other than 'True' and 'False' are ignored)")
|
support = NullBooleanField(
|
||||||
|
required=False, help_text=_("Support (values other than 'True' and 'False' are ignored)")
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SoftwareLicense
|
model = SoftwareLicense
|
||||||
@@ -155,8 +220,18 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
|
|||||||
"stored_location",
|
"stored_location",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"default_notification_intervals",
|
||||||
|
"custom_notification_interval",
|
||||||
|
"custom_notification_interval_unit",
|
||||||
|
"email_notifications",
|
||||||
"version",
|
"version",
|
||||||
"installation",
|
"installation",
|
||||||
"tenant_group",
|
"tenant_group",
|
||||||
@@ -175,8 +250,18 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
"stored_location_url",
|
"stored_location_url",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"default_notification_intervals",
|
||||||
|
"custom_notification_interval",
|
||||||
|
"custom_notification_interval_unit",
|
||||||
|
"email_notifications",
|
||||||
"software_product",
|
"software_product",
|
||||||
"version",
|
"version",
|
||||||
"installation",
|
"installation",
|
||||||
@@ -191,8 +276,14 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
"stored_location_url",
|
"stored_location_url",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
"custom_notification_interval",
|
||||||
|
"custom_notification_interval_unit",
|
||||||
"version",
|
"version",
|
||||||
"installation",
|
"installation",
|
||||||
"tenant_group",
|
"tenant_group",
|
||||||
@@ -203,18 +294,28 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
type = CharField(required=False)
|
type = CharField(required=False)
|
||||||
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label="SPDX expression")
|
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
|
||||||
stored_location = CharField(required=False)
|
stored_location = CharField(required=False)
|
||||||
stored_location_url = LaxURLField(required=False)
|
stored_location_url = LaxURLField(required=False)
|
||||||
start_date = DateField(required=False, widget=DatePicker())
|
start_date = DateField(required=False, widget=DatePicker())
|
||||||
expiration_date = DateField(required=False, widget=DatePicker())
|
expiration_date = DateField(required=False, widget=DatePicker())
|
||||||
|
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)
|
||||||
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
|
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
|
||||||
license_amount = IntegerField(required=False, min_value=0)
|
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(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version = DynamicModelChoiceField(
|
version = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
@@ -233,11 +334,11 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
tenant_group = DynamicModelChoiceField(
|
tenant_group = DynamicModelChoiceField(
|
||||||
queryset=TenantGroup.objects.all(),
|
queryset=TenantGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Tenant Group",
|
label=_("Tenant Group"),
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={"group_id": "$tenant_group"},
|
query_params={"group_id": "$tenant_group"},
|
||||||
label="Tenant",
|
label=_("Tenant"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.forms import CharField
|
from django.forms import CharField
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.models import Manufacturer
|
from dcim.models import Manufacturer
|
||||||
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
||||||
@@ -46,7 +47,7 @@ class SoftwareProductFilterForm(NetBoxModelFilterSetForm):
|
|||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Manufacturer",
|
label=_("Manufacturer"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
||||||
@@ -17,18 +18,32 @@ from virtualization.models import Cluster, VirtualMachine
|
|||||||
|
|
||||||
class SoftwareProductInstallationForm(NetBoxModelForm):
|
class SoftwareProductInstallationForm(NetBoxModelForm):
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet("device", "virtualmachine", "cluster", name=_("Platform Destination (select exactly one)")),
|
||||||
|
FieldSet("software_product", "version", name=_("Software")),
|
||||||
|
FieldSet("tags", name=_("Tags")),
|
||||||
|
)
|
||||||
|
|
||||||
device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False)
|
device = DynamicModelChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
required=False,
|
||||||
|
help_text=_("Select either a device, a virtual machine, or a cluster."),
|
||||||
|
)
|
||||||
virtualmachine = DynamicModelChoiceField(
|
virtualmachine = DynamicModelChoiceField(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Virtual Machine",
|
label=_("Virtual Machine"),
|
||||||
|
help_text=_("Leave empty when another platform destination is selected."),
|
||||||
|
)
|
||||||
|
cluster = DynamicModelChoiceField(
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
required=False,
|
||||||
|
help_text=_("Leave empty when another platform destination is selected."),
|
||||||
)
|
)
|
||||||
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
|
||||||
software_product = DynamicModelChoiceField(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=True,
|
required=True,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version = DynamicModelChoiceField(
|
version = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
@@ -54,11 +69,22 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
|
|||||||
software_product = self.cleaned_data["software_product"]
|
software_product = self.cleaned_data["software_product"]
|
||||||
if version not in software_product.softwareproductversion_set.all():
|
if version not in software_product.softwareproductversion_set.all():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Version '{version}' doesn't exist on {software_product}, make sure you've "
|
_("Version '%(version)s' does not belong to %(product)s. Select a compatible version.")
|
||||||
f"selected a compatible version or first select the software product."
|
% {"version": version, "product": software_product}
|
||||||
)
|
)
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
destinations = [
|
||||||
|
self.cleaned_data.get("device"),
|
||||||
|
self.cleaned_data.get("virtualmachine"),
|
||||||
|
self.cleaned_data.get("cluster"),
|
||||||
|
]
|
||||||
|
if sum(destination is not None for destination in destinations) != 1:
|
||||||
|
raise ValidationError(_("Select exactly one platform destination: device, virtual machine, or cluster."))
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
|
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
|
||||||
model = SoftwareProductInstallation
|
model = SoftwareProductInstallation
|
||||||
@@ -73,27 +99,27 @@ class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
|
|||||||
device_id = DynamicModelMultipleChoiceField(
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Device",
|
label=_("Device"),
|
||||||
)
|
)
|
||||||
virtualmachine_id = DynamicModelMultipleChoiceField(
|
virtualmachine_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Virtual Machine",
|
label=_("Virtual Machine"),
|
||||||
)
|
)
|
||||||
cluster_id = DynamicModelMultipleChoiceField(
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Cluster",
|
label=_("Cluster"),
|
||||||
)
|
)
|
||||||
software_product_id = DynamicModelMultipleChoiceField(
|
software_product_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version_id = DynamicModelMultipleChoiceField(
|
version_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Version",
|
label=_("Version"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,13 +148,13 @@ class SoftwareProductInstallationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
virtualmachine = DynamicModelChoiceField(
|
virtualmachine = DynamicModelChoiceField(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Virtual Machine",
|
label=_("Virtual Machine"),
|
||||||
)
|
)
|
||||||
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
||||||
software_product = DynamicModelChoiceField(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
version = DynamicModelChoiceField(
|
version = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProductVersion.objects.all(),
|
queryset=SoftwareProductVersion.objects.all(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.forms import DateField, CharField, MultipleChoiceField
|
from django.forms import DateField, CharField, MultipleChoiceField
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.models import Manufacturer
|
from dcim.models import Manufacturer
|
||||||
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
|
||||||
@@ -23,7 +24,7 @@ class SoftwareProductVersionForm(NetBoxModelForm):
|
|||||||
software_product = DynamicModelChoiceField(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=True,
|
required=True,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}),
|
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,17 +65,19 @@ class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm):
|
|||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Manufacturer",
|
label=_("Manufacturer"),
|
||||||
)
|
)
|
||||||
software_product_id = DynamicModelMultipleChoiceField(
|
software_product_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm):
|
class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm):
|
||||||
release_type = CharField(help_text=f"Release type (possible values: {SoftwareReleaseTypes.values})")
|
release_type = CharField(
|
||||||
|
help_text=_("Release type (possible values: %(values)s)") % {"values": SoftwareReleaseTypes.values}
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SoftwareProductVersion
|
model = SoftwareProductVersion
|
||||||
@@ -122,5 +125,5 @@ class SoftwareProductVersionBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
software_product = DynamicModelChoiceField(
|
software_product = DynamicModelChoiceField(
|
||||||
queryset=SoftwareProduct.objects.all(),
|
queryset=SoftwareProduct.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label="Software Product",
|
label=_("Software Product"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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,
|
||||||
|
)[0]
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
object_type = ContentType.objects.get_for_model(license)
|
||||||
|
Notification.objects.update_or_create(
|
||||||
|
object_type=object_type,
|
||||||
|
object_id=license.pk,
|
||||||
|
user=user,
|
||||||
|
defaults={
|
||||||
|
"created": now,
|
||||||
|
"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:
|
||||||
|
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:
|
||||||
|
from_email = settings.SERVER_EMAIL or settings.EMAIL_HOST_USER
|
||||||
|
send_mail(subject, body, 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.
@@ -0,0 +1,419 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: netbox-slm 1.9.1\n"
|
||||||
|
"Language: de\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
msgid "Add"
|
||||||
|
msgstr "Hinzufügen"
|
||||||
|
|
||||||
|
msgid "Import"
|
||||||
|
msgstr "Importieren"
|
||||||
|
|
||||||
|
msgid "Software Lifecycle"
|
||||||
|
msgstr "Software-Lebenszyklus"
|
||||||
|
|
||||||
|
msgid "Software Products"
|
||||||
|
msgstr "Softwareprodukte"
|
||||||
|
|
||||||
|
msgid "Software Product"
|
||||||
|
msgstr "Softwareprodukt"
|
||||||
|
|
||||||
|
msgid "Software Product Version"
|
||||||
|
msgstr "Softwareproduktversion"
|
||||||
|
|
||||||
|
msgid "Software Product Installation"
|
||||||
|
msgstr "Softwareproduktinstallation"
|
||||||
|
|
||||||
|
msgid "Software License"
|
||||||
|
msgstr "Softwarelizenz"
|
||||||
|
|
||||||
|
msgid "License Details"
|
||||||
|
msgstr "Lizenzdetails"
|
||||||
|
|
||||||
|
msgid "Versions"
|
||||||
|
msgstr "Versionen"
|
||||||
|
|
||||||
|
msgid "Version"
|
||||||
|
msgstr "Version"
|
||||||
|
|
||||||
|
msgid "Installations"
|
||||||
|
msgstr "Installationen"
|
||||||
|
|
||||||
|
msgid "Installation"
|
||||||
|
msgstr "Installation"
|
||||||
|
|
||||||
|
msgid "Add an installation"
|
||||||
|
msgstr "Installation hinzufügen"
|
||||||
|
|
||||||
|
msgid "Licenses"
|
||||||
|
msgstr "Lizenzen"
|
||||||
|
|
||||||
|
msgid "Tenancy"
|
||||||
|
msgstr "Mandantenzuordnung"
|
||||||
|
|
||||||
|
msgid "Tenant Group"
|
||||||
|
msgstr "Mandantengruppe"
|
||||||
|
|
||||||
|
msgid "Tenant"
|
||||||
|
msgstr "Mandant"
|
||||||
|
|
||||||
|
msgid "Manufacturer"
|
||||||
|
msgstr "Hersteller"
|
||||||
|
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Gerät"
|
||||||
|
|
||||||
|
msgid "Virtual Machine"
|
||||||
|
msgstr "Virtuelle Maschine"
|
||||||
|
|
||||||
|
msgid "Cluster"
|
||||||
|
msgstr "Cluster"
|
||||||
|
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "Name"
|
||||||
|
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Beschreibung"
|
||||||
|
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Typ"
|
||||||
|
|
||||||
|
msgid "SPDX expression"
|
||||||
|
msgstr "SPDX-Ausdruck"
|
||||||
|
|
||||||
|
msgid "Stored location"
|
||||||
|
msgstr "Ablageort"
|
||||||
|
|
||||||
|
msgid "Start date"
|
||||||
|
msgstr "Startdatum"
|
||||||
|
|
||||||
|
msgid "Expiration date"
|
||||||
|
msgstr "Ablaufdatum"
|
||||||
|
|
||||||
|
msgid "Support"
|
||||||
|
msgstr "Support"
|
||||||
|
|
||||||
|
msgid "License amount"
|
||||||
|
msgstr "Lizenzanzahl"
|
||||||
|
|
||||||
|
msgid "Release date"
|
||||||
|
msgstr "Veröffentlichungsdatum"
|
||||||
|
|
||||||
|
msgid "Documentation URL"
|
||||||
|
msgstr "Dokumentations-URL"
|
||||||
|
|
||||||
|
msgid "End of support"
|
||||||
|
msgstr "Supportende"
|
||||||
|
|
||||||
|
msgid "Filename"
|
||||||
|
msgstr "Dateiname"
|
||||||
|
|
||||||
|
msgid "File checksum"
|
||||||
|
msgstr "Dateiprüfsumme"
|
||||||
|
|
||||||
|
msgid "Release type"
|
||||||
|
msgstr "Veröffentlichungstyp"
|
||||||
|
|
||||||
|
msgid "Release candidate"
|
||||||
|
msgstr "Veröffentlichungskandidat"
|
||||||
|
|
||||||
|
msgid "Stable release"
|
||||||
|
msgstr "Stabile Veröffentlichung"
|
||||||
|
|
||||||
|
msgid "Alpha"
|
||||||
|
msgstr "Alpha"
|
||||||
|
|
||||||
|
msgid "Beta"
|
||||||
|
msgstr "Beta"
|
||||||
|
|
||||||
|
msgid "Tags"
|
||||||
|
msgstr "Tags"
|
||||||
|
|
||||||
|
msgid "None"
|
||||||
|
msgstr "Keine"
|
||||||
|
|
||||||
|
msgid "Link"
|
||||||
|
msgstr "Link"
|
||||||
|
|
||||||
|
msgid "Support (values other than 'True' and 'False' are ignored)"
|
||||||
|
msgstr "Support (andere Werte als ‚True‘ und ‚False‘ werden ignoriert)"
|
||||||
|
|
||||||
|
msgid "Release type (possible values: %(values)s)"
|
||||||
|
msgstr "Veröffentlichungstyp (mögliche Werte: %(values)s)"
|
||||||
|
|
||||||
|
msgid "Version '%(version)s' does not belong to %(product)s. Select a compatible version."
|
||||||
|
msgstr "Version ‚%(version)s‘ gehört nicht zu %(product)s. Wähle eine kompatible Version aus."
|
||||||
|
|
||||||
|
msgid "Installation requires exactly one platform destination."
|
||||||
|
msgstr "Eine Installation benötigt genau ein Plattformziel."
|
||||||
|
|
||||||
|
msgid "%(value)s is not a known SPDX license expression"
|
||||||
|
msgstr "%(value)s ist kein bekannter SPDX-Lizenzausdruck"
|
||||||
|
|
||||||
|
msgid "The selected tenant does not belong to the selected tenant group."
|
||||||
|
msgstr "Der ausgewählte Mandant gehört nicht zur ausgewählten Mandantengruppe."
|
||||||
|
|
||||||
|
msgid "Software Lifecycle Management"
|
||||||
|
msgstr "Software-Lebenszyklusverwaltung"
|
||||||
|
|
||||||
|
msgid "Software Lifecycle Management NetBox Plugin."
|
||||||
|
msgstr "NetBox-Plugin zur Verwaltung des Software-Lebenszyklus."
|
||||||
|
|
||||||
|
msgid "name"
|
||||||
|
msgstr "Name"
|
||||||
|
|
||||||
|
msgid "comments"
|
||||||
|
msgstr "Kommentare"
|
||||||
|
|
||||||
|
msgid "description"
|
||||||
|
msgstr "Beschreibung"
|
||||||
|
|
||||||
|
msgid "manufacturer"
|
||||||
|
msgstr "Hersteller"
|
||||||
|
|
||||||
|
msgid "software product"
|
||||||
|
msgstr "Softwareprodukt"
|
||||||
|
|
||||||
|
msgid "software products"
|
||||||
|
msgstr "Softwareprodukte"
|
||||||
|
|
||||||
|
msgid "release date"
|
||||||
|
msgstr "Veröffentlichungsdatum"
|
||||||
|
|
||||||
|
msgid "documentation URL"
|
||||||
|
msgstr "Dokumentations-URL"
|
||||||
|
|
||||||
|
msgid "end of support"
|
||||||
|
msgstr "Supportende"
|
||||||
|
|
||||||
|
msgid "filename"
|
||||||
|
msgstr "Dateiname"
|
||||||
|
|
||||||
|
msgid "file checksum"
|
||||||
|
msgstr "Dateiprüfsumme"
|
||||||
|
|
||||||
|
msgid "file link"
|
||||||
|
msgstr "Dateilink"
|
||||||
|
|
||||||
|
msgid "release type"
|
||||||
|
msgstr "Veröffentlichungstyp"
|
||||||
|
|
||||||
|
msgid "software product version"
|
||||||
|
msgstr "Softwareproduktversion"
|
||||||
|
|
||||||
|
msgid "software product versions"
|
||||||
|
msgstr "Softwareproduktversionen"
|
||||||
|
|
||||||
|
msgid "device"
|
||||||
|
msgstr "Gerät"
|
||||||
|
|
||||||
|
msgid "virtual machine"
|
||||||
|
msgstr "Virtuelle Maschine"
|
||||||
|
|
||||||
|
msgid "cluster"
|
||||||
|
msgstr "Cluster"
|
||||||
|
|
||||||
|
msgid "version"
|
||||||
|
msgstr "Version"
|
||||||
|
|
||||||
|
msgid "software product installation"
|
||||||
|
msgstr "Softwareproduktinstallation"
|
||||||
|
|
||||||
|
msgid "software product installations"
|
||||||
|
msgstr "Softwareproduktinstallationen"
|
||||||
|
|
||||||
|
msgid "type"
|
||||||
|
msgstr "Typ"
|
||||||
|
|
||||||
|
msgid "stored location"
|
||||||
|
msgstr "Ablageort"
|
||||||
|
|
||||||
|
msgid "stored location URL"
|
||||||
|
msgstr "Ablageort-URL"
|
||||||
|
|
||||||
|
msgid "start date"
|
||||||
|
msgstr "Startdatum"
|
||||||
|
|
||||||
|
msgid "expiration date"
|
||||||
|
msgstr "Ablaufdatum"
|
||||||
|
|
||||||
|
msgid "support"
|
||||||
|
msgstr "Support"
|
||||||
|
|
||||||
|
msgid "license amount"
|
||||||
|
msgstr "Lizenzanzahl"
|
||||||
|
|
||||||
|
msgid "installation"
|
||||||
|
msgstr "Installation"
|
||||||
|
|
||||||
|
msgid "tenant group"
|
||||||
|
msgstr "Mandantengruppe"
|
||||||
|
|
||||||
|
msgid "tenant"
|
||||||
|
msgstr "Mandant"
|
||||||
|
|
||||||
|
msgid "software license"
|
||||||
|
msgstr "Softwarelizenz"
|
||||||
|
|
||||||
|
msgid "software licenses"
|
||||||
|
msgstr "Softwarelizenzen"
|
||||||
|
|
||||||
|
msgid "Days"
|
||||||
|
msgstr "Tage"
|
||||||
|
|
||||||
|
msgid "Months"
|
||||||
|
msgstr "Monate"
|
||||||
|
|
||||||
|
msgid "Years"
|
||||||
|
msgstr "Jahre"
|
||||||
|
|
||||||
|
msgid "lifetime license"
|
||||||
|
msgstr "Lifetime-Lizenz"
|
||||||
|
|
||||||
|
msgid "renewal interval"
|
||||||
|
msgstr "Erneuerungsintervall"
|
||||||
|
|
||||||
|
msgid "renewal interval unit"
|
||||||
|
msgstr "Einheit des Erneuerungsintervalls"
|
||||||
|
|
||||||
|
msgid "license file"
|
||||||
|
msgstr "Lizenzdatei"
|
||||||
|
|
||||||
|
msgid "license key"
|
||||||
|
msgstr "Lizenzschlüssel"
|
||||||
|
|
||||||
|
msgid "provider portal URL"
|
||||||
|
msgstr "Anbieterportal-URL"
|
||||||
|
|
||||||
|
msgid "License Credentials"
|
||||||
|
msgstr "Lizenzdaten"
|
||||||
|
|
||||||
|
msgid "Lifetime license"
|
||||||
|
msgstr "Lifetime-Lizenz"
|
||||||
|
|
||||||
|
msgid "Renewal interval"
|
||||||
|
msgstr "Erneuerungsintervall"
|
||||||
|
|
||||||
|
msgid "License file"
|
||||||
|
msgstr "Lizenzdatei"
|
||||||
|
|
||||||
|
msgid "Download license file"
|
||||||
|
msgstr "Lizenzdatei herunterladen"
|
||||||
|
|
||||||
|
msgid "License key"
|
||||||
|
msgstr "Lizenzschlüssel"
|
||||||
|
|
||||||
|
msgid "Provider Portal"
|
||||||
|
msgstr "Anbieterportal"
|
||||||
|
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr "Ja"
|
||||||
|
|
||||||
|
msgid "No"
|
||||||
|
msgstr "Nein"
|
||||||
|
|
||||||
|
msgid "Renewal interval and unit must be specified together."
|
||||||
|
msgstr "Erneuerungsintervall und Einheit müssen gemeinsam angegeben werden."
|
||||||
|
|
||||||
|
msgid "A lifetime license cannot have a renewal interval or expiration date."
|
||||||
|
msgstr "Eine Lifetime-Lizenz darf kein Erneuerungsintervall und kein Ablaufdatum haben."
|
||||||
|
|
||||||
|
msgid "Platform Destination (select exactly one)"
|
||||||
|
msgstr "Plattformziel (genau eines auswählen)"
|
||||||
|
|
||||||
|
msgid "Software"
|
||||||
|
msgstr "Software"
|
||||||
|
|
||||||
|
msgid "Select either a device, a virtual machine, or a cluster."
|
||||||
|
msgstr "Wähle entweder ein Gerät, eine virtuelle Maschine oder einen Cluster aus."
|
||||||
|
|
||||||
|
msgid "Leave empty when another platform destination is selected."
|
||||||
|
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."
|
||||||
|
|
||||||
|
msgid "Can manage license email notifications"
|
||||||
|
msgstr "Kann E-Mail-Benachrichtigungen für Lizenzen verwalten"
|
||||||
|
|
||||||
|
msgid "You do not have permission to change email notifications."
|
||||||
|
msgstr "Sie haben keine Berechtigung, E-Mail-Benachrichtigungen zu ändern."
|
||||||
|
|
||||||
|
msgid "Only administrators or explicitly authorized users can change this setting."
|
||||||
|
msgstr "Nur Administratoren oder ausdrücklich berechtigte Benutzer können diese Einstellung ändern."
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.core.validators
|
||||||
|
import netbox_slm.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("netbox_slm", "0011_softwarelicense_tenancy"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(blank=True, max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="lifetime",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="renewal_interval",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="renewal_interval_unit",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[("days", "Days"), ("months", "Months"), ("years", "Years")],
|
||||||
|
max_length=8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="license_file",
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to="netbox_slm/licenses/"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="license_key",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="softwarelicense",
|
||||||
|
name="provider_portal_url",
|
||||||
|
field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("netbox_slm", "0013_license_expiration_notifications"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="softwarelicense",
|
||||||
|
options={
|
||||||
|
"permissions": [
|
||||||
|
(
|
||||||
|
"manage_softwarelicense_email_notifications",
|
||||||
|
"Can manage license email notifications",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"verbose_name": "software license",
|
||||||
|
"verbose_name_plural": "software licenses",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+199
-42
@@ -1,7 +1,13 @@
|
|||||||
|
import calendar
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from license_expression import Licensing, get_spdx_licensing
|
from license_expression import Licensing, get_spdx_licensing
|
||||||
|
|
||||||
from netbox.models import NetBoxModel
|
from netbox.models import NetBoxModel
|
||||||
@@ -21,14 +27,20 @@ class LaxURLField(models.URLField):
|
|||||||
|
|
||||||
|
|
||||||
class SoftwareProduct(NetBoxModel):
|
class SoftwareProduct(NetBoxModel):
|
||||||
name = models.CharField(max_length=128)
|
name = models.CharField(_("name"), max_length=128)
|
||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(_("comments"), blank=True)
|
||||||
|
|
||||||
description = models.CharField(max_length=255, null=True, blank=True)
|
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
|
||||||
manufacturer = models.ForeignKey(to="dcim.Manufacturer", on_delete=models.PROTECT, 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()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("software product")
|
||||||
|
verbose_name_plural = _("software products")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -44,25 +56,38 @@ class SoftwareProduct(NetBoxModel):
|
|||||||
|
|
||||||
|
|
||||||
class SoftwareReleaseTypes(models.TextChoices):
|
class SoftwareReleaseTypes(models.TextChoices):
|
||||||
ALPHA = "A", "Alpha"
|
ALPHA = "A", _("Alpha")
|
||||||
BETA = "B", "Beta"
|
BETA = "B", _("Beta")
|
||||||
RELEASE_CANDIDATE = "RC", "Release candidate"
|
RELEASE_CANDIDATE = "RC", _("Release candidate")
|
||||||
STABLE = "S", "Stable release"
|
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):
|
class SoftwareProductVersion(NetBoxModel):
|
||||||
name = models.CharField(max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(_("comments"), blank=True)
|
||||||
|
|
||||||
description = models.CharField(max_length=255, null=True, blank=True)
|
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
|
||||||
release_date = models.DateField(null=True, blank=True)
|
release_date = models.DateField(_("release date"), null=True, blank=True)
|
||||||
documentation_url = LaxURLField(max_length=1024, null=True, blank=True)
|
documentation_url = LaxURLField(_("documentation URL"), max_length=1024, null=True, blank=True)
|
||||||
end_of_support = models.DateField(null=True, blank=True)
|
end_of_support = models.DateField(_("end of support"), null=True, blank=True)
|
||||||
filename = models.CharField(max_length=64, null=True, blank=True)
|
filename = models.CharField(_("filename"), max_length=64, null=True, blank=True)
|
||||||
file_checksum = models.CharField(max_length=128, null=True, blank=True)
|
file_checksum = models.CharField(_("file checksum"), max_length=128, null=True, blank=True)
|
||||||
file_link = LaxURLField(max_length=1024, null=True, blank=True)
|
file_link = LaxURLField(_("file link"), max_length=1024, null=True, blank=True)
|
||||||
|
|
||||||
release_type = models.CharField(
|
release_type = models.CharField(
|
||||||
|
_("release type"),
|
||||||
max_length=3,
|
max_length=3,
|
||||||
choices=SoftwareReleaseTypes.choices,
|
choices=SoftwareReleaseTypes.choices,
|
||||||
default=SoftwareReleaseTypes.STABLE,
|
default=SoftwareReleaseTypes.STABLE,
|
||||||
@@ -70,11 +95,16 @@ class SoftwareProductVersion(NetBoxModel):
|
|||||||
|
|
||||||
software_product = models.ForeignKey(
|
software_product = models.ForeignKey(
|
||||||
to="netbox_slm.SoftwareProduct",
|
to="netbox_slm.SoftwareProduct",
|
||||||
|
verbose_name=_("software product"),
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("software product version")
|
||||||
|
verbose_name_plural = _("software product versions")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -88,15 +118,27 @@ class SoftwareProductVersion(NetBoxModel):
|
|||||||
|
|
||||||
|
|
||||||
class SoftwareProductInstallation(NetBoxModel):
|
class SoftwareProductInstallation(NetBoxModel):
|
||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(_("comments"), blank=True)
|
||||||
|
|
||||||
device = models.ForeignKey(to="dcim.Device", on_delete=models.PROTECT, null=True, blank=True)
|
device = models.ForeignKey(
|
||||||
virtualmachine = models.ForeignKey(
|
to="dcim.Device", verbose_name=_("device"), on_delete=models.PROTECT, null=True, blank=True
|
||||||
to="virtualization.VirtualMachine", 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
|
||||||
)
|
)
|
||||||
cluster = models.ForeignKey(to="virtualization.Cluster", on_delete=models.PROTECT, null=True, blank=True)
|
|
||||||
software_product = models.ForeignKey(to="netbox_slm.SoftwareProduct", on_delete=models.PROTECT)
|
|
||||||
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -104,6 +146,8 @@ class SoftwareProductInstallation(NetBoxModel):
|
|||||||
return f"{self.pk} ({self.platform})"
|
return f"{self.pk} ({self.platform})"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = _("software product installation")
|
||||||
|
verbose_name_plural = _("software product installations")
|
||||||
constraints = [
|
constraints = [
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
name="%(app_label)s_%(class)s_platform",
|
name="%(app_label)s_%(class)s_platform",
|
||||||
@@ -112,7 +156,9 @@ class SoftwareProductInstallation(NetBoxModel):
|
|||||||
| models.Q(device__isnull=True, virtualmachine__isnull=False, 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)
|
| models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False)
|
||||||
),
|
),
|
||||||
violation_error_message="Installation requires exactly one platform destination.",
|
violation_error_message=_(
|
||||||
|
"Select exactly one platform destination: device, virtual machine, or cluster."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -141,30 +187,67 @@ def spdx_license_names():
|
|||||||
def validate_spdx_expression(value):
|
def validate_spdx_expression(value):
|
||||||
expression_info = spdx_licensing.validate(value)
|
expression_info = spdx_licensing.validate(value)
|
||||||
if expression_info.errors:
|
if expression_info.errors:
|
||||||
raise ValidationError(f"{value} is not a known SPDX license expression")
|
raise ValidationError(_("%(value)s is not a known SPDX license expression") % {"value": value})
|
||||||
|
|
||||||
|
|
||||||
class SoftwareLicense(NetBoxModel):
|
class SoftwareLicense(NetBoxModel):
|
||||||
name = models.CharField(max_length=128)
|
name = models.CharField(_("name"), max_length=128)
|
||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(_("comments"), blank=True)
|
||||||
|
|
||||||
description = models.CharField(max_length=255, null=True, blank=True)
|
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
|
||||||
type = models.CharField(max_length=128)
|
type = models.CharField(_("type"), max_length=128, blank=True)
|
||||||
spdx_expression = models.CharField(max_length=64, null=True, blank=True, validators=[validate_spdx_expression])
|
spdx_expression = models.CharField(
|
||||||
stored_location = models.CharField(max_length=255, null=True, blank=True)
|
_("SPDX expression"), max_length=64, null=True, blank=True, validators=[validate_spdx_expression]
|
||||||
stored_location_url = LaxURLField(max_length=1024, null=True, blank=True)
|
)
|
||||||
start_date = models.DateField(null=True, blank=True)
|
stored_location = models.CharField(_("stored location"), max_length=255, null=True, blank=True)
|
||||||
expiration_date = models.DateField(null=True, blank=True)
|
stored_location_url = LaxURLField(_("stored location URL"), max_length=1024, null=True, blank=True)
|
||||||
support = models.BooleanField(default=None, null=True, blank=True)
|
start_date = models.DateField(_("start date"), null=True, blank=True)
|
||||||
license_amount = models.PositiveIntegerField(default=None, 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", on_delete=models.PROTECT)
|
software_product = models.ForeignKey(
|
||||||
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT, null=True, blank=True)
|
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(
|
installation = models.ForeignKey(
|
||||||
to="netbox_slm.SoftwareProductInstallation", on_delete=models.SET_NULL, null=True, blank=True
|
to="netbox_slm.SoftwareProductInstallation",
|
||||||
|
verbose_name=_("installation"),
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
tenant_group = models.ForeignKey(
|
tenant_group = models.ForeignKey(
|
||||||
to="tenancy.TenantGroup",
|
to="tenancy.TenantGroup",
|
||||||
|
verbose_name=_("tenant group"),
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -172,6 +255,7 @@ class SoftwareLicense(NetBoxModel):
|
|||||||
)
|
)
|
||||||
tenant = models.ForeignKey(
|
tenant = models.ForeignKey(
|
||||||
to="tenancy.Tenant",
|
to="tenancy.Tenant",
|
||||||
|
verbose_name=_("tenant"),
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -180,6 +264,13 @@ class SoftwareLicense(NetBoxModel):
|
|||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -189,10 +280,76 @@ class SoftwareLicense(NetBoxModel):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.tenant_group and self.tenant and self.tenant.group_id != self.tenant_group_id:
|
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."})
|
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
|
@property
|
||||||
def stored_location_txt(self):
|
def stored_location_txt(self):
|
||||||
if self.stored_location_url and not self.stored_location:
|
if self.stored_location_url and not self.stored_location:
|
||||||
return "Link"
|
return _("Link")
|
||||||
return self.stored_location
|
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",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|||||||
+14
-13
@@ -1,21 +1,22 @@
|
|||||||
from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config
|
from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from . import SLMConfig
|
from . import SLMConfig
|
||||||
|
|
||||||
slm_items = (
|
slm_items = (
|
||||||
PluginMenuItem(
|
PluginMenuItem(
|
||||||
link="plugins:netbox_slm:softwareproduct_list",
|
link="plugins:netbox_slm:softwareproduct_list",
|
||||||
link_text="Software Products",
|
link_text=_("Software Products"),
|
||||||
permissions=["netbox_slm.add_softwareproduct"],
|
permissions=["netbox_slm.add_softwareproduct"],
|
||||||
buttons=(
|
buttons=(
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproduct_add",
|
"plugins:netbox_slm:softwareproduct_add",
|
||||||
"Add",
|
_("Add"),
|
||||||
"mdi mdi-plus-thick",
|
"mdi mdi-plus-thick",
|
||||||
permissions=["netbox_slm.add_softwareproduct"],
|
permissions=["netbox_slm.add_softwareproduct"],
|
||||||
),
|
),
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproduct_bulk_import",
|
"plugins:netbox_slm:softwareproduct_bulk_import",
|
||||||
"Import",
|
_("Import"),
|
||||||
"mdi mdi-upload",
|
"mdi mdi-upload",
|
||||||
permissions=["netbox_slm.import_softwareproduct"],
|
permissions=["netbox_slm.import_softwareproduct"],
|
||||||
),
|
),
|
||||||
@@ -23,18 +24,18 @@ slm_items = (
|
|||||||
),
|
),
|
||||||
PluginMenuItem(
|
PluginMenuItem(
|
||||||
link="plugins:netbox_slm:softwareproductversion_list",
|
link="plugins:netbox_slm:softwareproductversion_list",
|
||||||
link_text="Versions",
|
link_text=_("Versions"),
|
||||||
permissions=["netbox_slm.add_softwareproductversion"],
|
permissions=["netbox_slm.add_softwareproductversion"],
|
||||||
buttons=(
|
buttons=(
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproductversion_add",
|
"plugins:netbox_slm:softwareproductversion_add",
|
||||||
"Add",
|
_("Add"),
|
||||||
"mdi mdi-plus-thick",
|
"mdi mdi-plus-thick",
|
||||||
permissions=["netbox_slm.add_softwareproductversion"],
|
permissions=["netbox_slm.add_softwareproductversion"],
|
||||||
),
|
),
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproductversion_bulk_import",
|
"plugins:netbox_slm:softwareproductversion_bulk_import",
|
||||||
"Import",
|
_("Import"),
|
||||||
"mdi mdi-upload",
|
"mdi mdi-upload",
|
||||||
permissions=["netbox_slm.import_softwareproductversion"],
|
permissions=["netbox_slm.import_softwareproductversion"],
|
||||||
),
|
),
|
||||||
@@ -42,18 +43,18 @@ slm_items = (
|
|||||||
),
|
),
|
||||||
PluginMenuItem(
|
PluginMenuItem(
|
||||||
link="plugins:netbox_slm:softwareproductinstallation_list",
|
link="plugins:netbox_slm:softwareproductinstallation_list",
|
||||||
link_text="Installations",
|
link_text=_("Installations"),
|
||||||
permissions=["netbox_slm.add_softwareproductinstallation"],
|
permissions=["netbox_slm.add_softwareproductinstallation"],
|
||||||
buttons=(
|
buttons=(
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproductinstallation_add",
|
"plugins:netbox_slm:softwareproductinstallation_add",
|
||||||
"Add",
|
_("Add"),
|
||||||
"mdi mdi-plus-thick",
|
"mdi mdi-plus-thick",
|
||||||
permissions=["netbox_slm.add_softwareproductinstallation"],
|
permissions=["netbox_slm.add_softwareproductinstallation"],
|
||||||
),
|
),
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwareproductinstallation_bulk_import",
|
"plugins:netbox_slm:softwareproductinstallation_bulk_import",
|
||||||
"Import",
|
_("Import"),
|
||||||
"mdi mdi-upload",
|
"mdi mdi-upload",
|
||||||
permissions=["netbox_slm.import_softwareproductinstallation"],
|
permissions=["netbox_slm.import_softwareproductinstallation"],
|
||||||
),
|
),
|
||||||
@@ -61,18 +62,18 @@ slm_items = (
|
|||||||
),
|
),
|
||||||
PluginMenuItem(
|
PluginMenuItem(
|
||||||
link="plugins:netbox_slm:softwarelicense_list",
|
link="plugins:netbox_slm:softwarelicense_list",
|
||||||
link_text="Licenses",
|
link_text=_("Licenses"),
|
||||||
permissions=["netbox_slm.add_softwarelicense"],
|
permissions=["netbox_slm.add_softwarelicense"],
|
||||||
buttons=(
|
buttons=(
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwarelicense_add",
|
"plugins:netbox_slm:softwarelicense_add",
|
||||||
"Add",
|
_("Add"),
|
||||||
"mdi mdi-plus-thick",
|
"mdi mdi-plus-thick",
|
||||||
permissions=["netbox_slm.add_softwarelicense"],
|
permissions=["netbox_slm.add_softwarelicense"],
|
||||||
),
|
),
|
||||||
PluginMenuButton(
|
PluginMenuButton(
|
||||||
"plugins:netbox_slm:softwarelicense_bulk_import",
|
"plugins:netbox_slm:softwarelicense_bulk_import",
|
||||||
"Import",
|
_("Import"),
|
||||||
"mdi mdi-upload",
|
"mdi mdi-upload",
|
||||||
permissions=["netbox_slm.import_softwarelicense"],
|
permissions=["netbox_slm.import_softwarelicense"],
|
||||||
),
|
),
|
||||||
@@ -82,7 +83,7 @@ slm_items = (
|
|||||||
|
|
||||||
if get_plugin_config("netbox_slm", "top_level_menu"):
|
if get_plugin_config("netbox_slm", "top_level_menu"):
|
||||||
menu = PluginMenu(
|
menu = PluginMenu(
|
||||||
label="Software Lifecycle",
|
label=_("Licences"),
|
||||||
groups=((SLMConfig.verbose_name, slm_items),),
|
groups=((SLMConfig.verbose_name, slm_items),),
|
||||||
icon_class="mdi mdi-content-save",
|
icon_class="mdi mdi-content-save",
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-5
@@ -1,5 +1,6 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.db.models import Count, F, Value
|
from django.db.models import Count, F, Value
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, ToggleColumn, columns
|
from netbox.tables import NetBoxTable, ToggleColumn, columns
|
||||||
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
|
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
|
||||||
@@ -45,7 +46,7 @@ class SoftwareProductVersionTable(NetBoxTable):
|
|||||||
|
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
|
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
|
||||||
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
||||||
installations = tables.Column(accessor="get_installation_count")
|
installations = tables.Column(accessor="get_installation_count")
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ class SoftwareProductInstallationTable(NetBoxTable):
|
|||||||
platform = tables.Column(accessor="platform", linkify=True)
|
platform = tables.Column(accessor="platform", linkify=True)
|
||||||
type = tables.Column(accessor="render_type")
|
type = tables.Column(accessor="render_type")
|
||||||
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
||||||
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
|
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
|
||||||
version = tables.Column(accessor="version", linkify=True)
|
version = tables.Column(accessor="version", linkify=True)
|
||||||
|
|
||||||
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list")
|
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list")
|
||||||
@@ -137,15 +138,16 @@ class SoftwareLicenseTable(NetBoxTable):
|
|||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
|
|
||||||
type = tables.Column()
|
type = tables.Column()
|
||||||
spdx_expression = tables.Column(verbose_name="SPDX expression")
|
spdx_expression = tables.Column(verbose_name=_("SPDX expression"))
|
||||||
stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url)
|
stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url)
|
||||||
|
|
||||||
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
|
||||||
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True)
|
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
|
||||||
version = tables.Column(accessor="version", linkify=True)
|
version = tables.Column(accessor="version", linkify=True)
|
||||||
installation = tables.Column(accessor="installation", linkify=True)
|
installation = tables.Column(accessor="installation", linkify=True)
|
||||||
tenant_group = tables.Column(accessor="tenant_group", verbose_name="Tenant Group", linkify=True)
|
tenant_group = tables.Column(accessor="tenant_group", verbose_name=_("Tenant Group"), linkify=True)
|
||||||
tenant = tables.Column(accessor="tenant", linkify=True)
|
tenant = tables.Column(accessor="tenant", linkify=True)
|
||||||
|
provider_portal_url = tables.Column(verbose_name=_("Provider Portal"), linkify=True)
|
||||||
|
|
||||||
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list")
|
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list")
|
||||||
|
|
||||||
@@ -160,6 +162,9 @@ class SoftwareLicenseTable(NetBoxTable):
|
|||||||
"stored_location",
|
"stored_location",
|
||||||
"start_date",
|
"start_date",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
"manufacturer",
|
"manufacturer",
|
||||||
"software_product",
|
"software_product",
|
||||||
"version",
|
"version",
|
||||||
@@ -168,6 +173,9 @@ class SoftwareLicenseTable(NetBoxTable):
|
|||||||
"tenant",
|
"tenant",
|
||||||
"support",
|
"support",
|
||||||
"license_amount",
|
"license_amount",
|
||||||
|
"provider_portal_url",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
|
"email_notifications",
|
||||||
"tags",
|
"tags",
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
@@ -180,6 +188,8 @@ class SoftwareLicenseTable(NetBoxTable):
|
|||||||
"tenant_group",
|
"tenant_group",
|
||||||
"tenant",
|
"tenant",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
|
"lifetime",
|
||||||
|
"expiration_notifications_enabled",
|
||||||
"tags",
|
"tags",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-header">
|
<h2 class="card-header">
|
||||||
Installations
|
{% trans "Installations" %}
|
||||||
{% if perms.netbox_slm.add_softwareproductinstallation %}
|
{% if perms.netbox_slm.add_softwareproductinstallation %}
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation_add' %}?{{ search_obj }}={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation_add' %}?{{ search_obj }}={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
||||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add an installation
|
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an installation" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,53 +3,54 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row my-3">
|
<div class="row my-3">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">
|
||||||
Software License
|
{% trans "Software License" %}
|
||||||
</h5>
|
</h5>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
<td>{{ object.name }}</td>
|
<td>{{ object.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
<td>{{ object.description }}</td>
|
<td>{{ object.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Type</th>
|
<th scope="row">{% trans "Type" %}</th>
|
||||||
<td>{{ object.type }}</td>
|
<td>{{ object.type }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">SPDX expression</th>
|
<th scope="row">{% trans "SPDX expression" %}</th>
|
||||||
<td>{{ object.spdx_expression }}</td>
|
<td>{{ object.spdx_expression }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Software Product</th>
|
<th scope="row">{% trans "Software Product" %}</th>
|
||||||
<td>{{ object.software_product|linkify }}</td>
|
<td>{{ object.software_product|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Version</th>
|
<th scope="row">{% trans "Version" %}</th>
|
||||||
<td>{{ object.version|linkify }}</td>
|
<td>{{ object.version|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Installation</th>
|
<th scope="row">{% trans "Installation" %}</th>
|
||||||
<td>{{ object.installation|linkify }}</td>
|
<td>{{ object.installation|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Tenant Group</th>
|
<th scope="row">{% trans "Tenant Group" %}</th>
|
||||||
<td>{{ object.tenant_group|linkify }}</td>
|
<td>{{ object.tenant_group|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Tenant</th>
|
<th scope="row">{% trans "Tenant" %}</th>
|
||||||
<td>{{ object.tenant|linkify }}</td>
|
<td>{{ object.tenant|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Stored location</th>
|
<th scope="row">{% trans "Stored location" %}</th>
|
||||||
{% if object.stored_location_url %}
|
{% if object.stored_location_url %}
|
||||||
<td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td>
|
<td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -57,21 +58,97 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Start date</th>
|
<th scope="row">{% trans "Start date" %}</th>
|
||||||
<td>{{ object.start_date }}</td>
|
<td>{{ object.start_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Expiration date</th>
|
<th scope="row">{% trans "Expiration date" %}</th>
|
||||||
<td>{{ object.expiration_date }}</td>
|
<td>{{ object.expiration_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Support</th>
|
<th scope="row">{% trans "Lifetime license" %}</th>
|
||||||
|
<td>{% if object.lifetime %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Renewal interval" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if object.renewal_interval %}
|
||||||
|
{{ object.renewal_interval }} {{ object.get_renewal_interval_unit_display }}
|
||||||
|
{% else %}
|
||||||
|
{{ None|placeholder }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Support" %}</th>
|
||||||
<td>{{ object.support }}</td>
|
<td>{{ object.support }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">License amount</th>
|
<th scope="row">{% trans "License amount" %}</th>
|
||||||
<td>{{ object.license_amount }}</td>
|
<td>{{ object.license_amount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "License file" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if object.license_file %}
|
||||||
|
<a href="{{ object.license_file.url }}">{% trans "Download license file" %}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ None|placeholder }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "License key" %}</th>
|
||||||
|
<td>{% if object.license_key %}<code>{{ object.license_key }}</code>{% else %}{{ None|placeholder }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Provider Portal" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if object.provider_portal_url %}
|
||||||
|
<a href="{{ object.provider_portal_url }}">{{ object.provider_portal_url }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ None|placeholder }}
|
||||||
|
{% 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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
|
|||||||
@@ -3,60 +3,61 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row my-3">
|
<div class="row my-3">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">
|
||||||
Software Product
|
{% trans "Software Product" %}
|
||||||
</h5>
|
</h5>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
<td>{{ object.name }}</td>
|
<td>{{ object.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
<td>{{ object.description }}</td>
|
<td>{{ object.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Manufacturer</th>
|
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||||
<td>{{ object.manufacturer|linkify }}</td>
|
<td>{{ object.manufacturer|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Versions</th>
|
<th scope="row">{% trans "Versions" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for version in object.softwareproductversion_set.all %}
|
{% for version in object.softwareproductversion_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm">
|
||||||
{{ version }}
|
{{ version }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Installations</th>
|
<th scope="row">{% trans "Installations" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for installation in object.softwareproductinstallation_set.all %}
|
{% for installation in object.softwareproductinstallation_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
|
||||||
{{ installation }}
|
{{ installation }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Licenses</th>
|
<th scope="row">{% trans "Licenses" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for license in object.softwarelicense_set.all %}
|
{% for license in object.softwarelicense_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
||||||
{{ license }}
|
{{ license }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,48 +3,49 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row my-3">
|
<div class="row my-3">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">
|
||||||
Software Product Installation
|
{% trans "Software Product Installation" %}
|
||||||
</h5>
|
</h5>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
{% if object.device %}
|
{% if object.device %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Device</th>
|
<th scope="row">{% trans "Device" %}</th>
|
||||||
<td>{{ object.device|linkify }}</td>
|
<td>{{ object.device|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% elif object.virtualmachine %}
|
{% elif object.virtualmachine %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Virtual Machine</th>
|
<th scope="row">{% trans "Virtual Machine" %}</th>
|
||||||
<td>{{ object.virtualmachine|linkify }}</td>
|
<td>{{ object.virtualmachine|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Cluster</th>
|
<th scope="row">{% trans "Cluster" %}</th>
|
||||||
<td>{{ object.cluster|linkify }}</td>
|
<td>{{ object.cluster|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Software Product</th>
|
<th scope="row">{% trans "Software Product" %}</th>
|
||||||
<td>{{ object.software_product|linkify }}</td>
|
<td>{{ object.software_product|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Version</th>
|
<th scope="row">{% trans "Version" %}</th>
|
||||||
<td>{{ object.version|linkify }}</td>
|
<td>{{ object.version|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Licenses</th>
|
<th scope="row">{% trans "Licenses" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for license in object.softwarelicense_set.all %}
|
{% for license in object.softwarelicense_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
||||||
{{ license }}
|
{{ license }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,37 +3,38 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row my-3">
|
<div class="row my-3">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">
|
||||||
Software Product Version
|
{% trans "Software Product Version" %}
|
||||||
</h5>
|
</h5>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
<td>{{ object.name }}</td>
|
<td>{{ object.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
<td>{{ object.description }}</td>
|
<td>{{ object.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Manufacturer</th>
|
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||||
<td>{{ object.software_product.manufacturer|linkify }}</td>
|
<td>{{ object.software_product.manufacturer|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Software Product</th>
|
<th scope="row">{% trans "Software Product" %}</th>
|
||||||
<td>{{ object.software_product|linkify }}</td>
|
<td>{{ object.software_product|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Release date</th>
|
<th scope="row">{% trans "Release date" %}</th>
|
||||||
<td>{{ object.release_date }}</td>
|
<td>{{ object.release_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Documentation url</th>
|
<th scope="row">{% trans "Documentation URL" %}</th>
|
||||||
{% if object.documentation_url %}
|
{% if object.documentation_url %}
|
||||||
<td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td>
|
<td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -41,11 +42,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">End of support</th>
|
<th scope="row">{% trans "End of support" %}</th>
|
||||||
<td>{{ object.end_of_support }}</td>
|
<td>{{ object.end_of_support }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Filename</th>
|
<th scope="row">{% trans "Filename" %}</th>
|
||||||
{% if object.file_link %}
|
{% if object.file_link %}
|
||||||
<td><a href="{{ object.file_link }}">{{ object.filename }}</a></td>
|
<td><a href="{{ object.file_link }}">{{ object.filename }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -53,34 +54,34 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">File checksum</th>
|
<th scope="row">{% trans "File checksum" %}</th>
|
||||||
<td>{{ object.file_checksum }}</td>
|
<td>{{ object.file_checksum }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Release type</th>
|
<th scope="row">{% trans "Release type" %}</th>
|
||||||
<td>{{ object.get_release_type_display }}</td>
|
<td>{{ object.get_release_type_display }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Installations</th>
|
<th scope="row">{% trans "Installations" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for installation in object.softwareproductinstallation_set.all %}
|
{% for installation in object.softwareproductinstallation_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
|
||||||
{{ installation }}
|
{{ installation }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Licenses</th>
|
<th scope="row">{% trans "Licenses" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for license in object.softwarelicense_set.all %}
|
{% for license in object.softwarelicense_set.all %}
|
||||||
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
|
||||||
{{ license }}
|
{{ license }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
{% trans "None" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
|
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.core.exceptions import ValidationError
|
||||||
|
from django.test import override_settings
|
||||||
|
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.jobs import LicenseExpirationNotificationJob
|
||||||
|
from netbox_slm.models import (
|
||||||
|
NotificationIntervalUnits,
|
||||||
|
RenewalIntervalUnits,
|
||||||
|
SoftwareLicense,
|
||||||
|
SoftwareLicenseNotificationLog,
|
||||||
|
)
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from .base import SlmBaseTestCase
|
from .base import SlmBaseTestCase
|
||||||
@@ -53,6 +71,25 @@ class ModelTestCase(SlmBaseTestCase):
|
|||||||
self.assertEqual("cluster", self.software_product_installation.render_type())
|
self.assertEqual("cluster", self.software_product_installation.render_type())
|
||||||
self.assertEqual(self.cluster, self.software_product_installation.platform)
|
self.assertEqual(self.cluster, self.software_product_installation.platform)
|
||||||
|
|
||||||
|
def test_installation_form_requires_exactly_one_platform_destination(self):
|
||||||
|
base_data = {
|
||||||
|
"software_product": self.software_product.pk,
|
||||||
|
"version": self.software_product_version.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
no_destination_form = SoftwareProductInstallationForm(data=base_data)
|
||||||
|
self.assertFalse(no_destination_form.is_valid())
|
||||||
|
self.assertIn("Select exactly one platform destination", str(no_destination_form.non_field_errors()))
|
||||||
|
|
||||||
|
one_destination_form = SoftwareProductInstallationForm(data={**base_data, "device": self.device.pk})
|
||||||
|
self.assertTrue(one_destination_form.is_valid(), one_destination_form.errors)
|
||||||
|
|
||||||
|
multiple_destinations_form = SoftwareProductInstallationForm(
|
||||||
|
data={**base_data, "device": self.device.pk, "cluster": self.cluster.pk}
|
||||||
|
)
|
||||||
|
self.assertFalse(multiple_destinations_form.is_valid())
|
||||||
|
self.assertIn("Select exactly one platform destination", str(multiple_destinations_form.non_field_errors()))
|
||||||
|
|
||||||
def test_software_license_stored_location_txt(self):
|
def test_software_license_stored_location_txt(self):
|
||||||
self.assertEqual("Link", self.software_license.stored_location_txt)
|
self.assertEqual("Link", self.software_license.stored_location_txt)
|
||||||
|
|
||||||
@@ -64,6 +101,106 @@ class ModelTestCase(SlmBaseTestCase):
|
|||||||
self.assertEqual(self.tenant_group, self.software_license.tenant_group)
|
self.assertEqual(self.tenant_group, self.software_license.tenant_group)
|
||||||
self.assertEqual(self.tenant, self.software_license.tenant)
|
self.assertEqual(self.tenant, self.software_license.tenant)
|
||||||
|
|
||||||
|
def test_software_license_form_renders_tenancy_fields(self):
|
||||||
|
form = SoftwareLicenseForm(instance=self.software_license)
|
||||||
|
self.assertIn("tenant_group", form.fields)
|
||||||
|
self.assertIn("tenant", form.fields)
|
||||||
|
self.assertTrue(
|
||||||
|
any("tenant_group" in fieldset.items and "tenant" in fieldset.items for fieldset in form.fieldsets)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_software_license_form_contains_renewal_and_credentials(self):
|
||||||
|
form = SoftwareLicenseForm(instance=self.software_license)
|
||||||
|
for field_name in (
|
||||||
|
"lifetime",
|
||||||
|
"renewal_interval",
|
||||||
|
"renewal_interval_unit",
|
||||||
|
"license_file",
|
||||||
|
"license_key",
|
||||||
|
"provider_portal_url",
|
||||||
|
):
|
||||||
|
self.assertIn(field_name, form.fields)
|
||||||
|
|
||||||
|
def test_software_license_accepts_optional_type_and_renewal_interval(self):
|
||||||
|
license = SoftwareLicense(
|
||||||
|
name="renewable license",
|
||||||
|
software_product=self.software_product,
|
||||||
|
renewal_interval=12,
|
||||||
|
renewal_interval_unit=RenewalIntervalUnits.MONTHS,
|
||||||
|
)
|
||||||
|
license.full_clean()
|
||||||
|
|
||||||
|
def test_software_license_requires_renewal_interval_and_unit_together(self):
|
||||||
|
license = SoftwareLicense(
|
||||||
|
name="invalid renewable license",
|
||||||
|
software_product=self.software_product,
|
||||||
|
renewal_interval=12,
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(ValidationError, "must be specified together"):
|
||||||
|
license.full_clean()
|
||||||
|
|
||||||
|
def test_lifetime_license_rejects_expiration(self):
|
||||||
|
license = SoftwareLicense(
|
||||||
|
name="invalid lifetime license",
|
||||||
|
software_product=self.software_product,
|
||||||
|
lifetime=True,
|
||||||
|
expiration_date="2030-01-01",
|
||||||
|
)
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(SERVER_EMAIL="netbox@example.com", EMAIL_HOST_USER="smtp-user")
|
||||||
|
@patch("netbox_slm.jobs.send_mail")
|
||||||
|
def test_expiration_job_notifies_subscribers_on_every_run(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()
|
||||||
|
|
||||||
|
notification = Notification.objects.get(user=user, object_id=license.pk)
|
||||||
|
self.assertIsInstance(str(notification.event), str)
|
||||||
|
self.assertEqual(1, SoftwareLicenseNotificationLog.objects.filter(user=user, license=license).count())
|
||||||
|
self.assertEqual(2, send_mail.call_count)
|
||||||
|
self.assertEqual("netbox@example.com", send_mail.call_args.args[2])
|
||||||
|
|
||||||
|
def test_german_translation(self):
|
||||||
|
with override("de"):
|
||||||
|
self.assertEqual("Softwarelizenz", gettext("Software License"))
|
||||||
|
self.assertEqual("Mandantengruppe", gettext("Tenant Group"))
|
||||||
|
self.assertEqual("Lizenzen", gettext("Licenses"))
|
||||||
|
|
||||||
def test_software_license_rejects_tenant_from_other_group(self):
|
def test_software_license_rejects_tenant_from_other_group(self):
|
||||||
other_group = TenantGroup.objects.create(name="other group", slug="other-group")
|
other_group = TenantGroup.objects.create(name="other group", slug="other-group")
|
||||||
other_tenant = Tenant.objects.create(name="other tenant", slug="other-tenant", group=other_group)
|
other_tenant = Tenant.objects.create(name="other tenant", slug="other-tenant", group=other_group)
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from netbox_slm import filtersets, forms, tables
|
from netbox_slm import filtersets, forms, tables
|
||||||
from netbox_slm.models import SoftwareLicense
|
from netbox_slm.models import SoftwareLicense
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationPermissionMixin:
|
||||||
|
"""Prevent users without the dedicated permission from changing SMTP delivery."""
|
||||||
|
|
||||||
|
email_permission = "netbox_slm.manage_softwarelicense_email_notifications"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
form_attribute = "model_form" if hasattr(self, "model_form") else "form"
|
||||||
|
base_form = getattr(self, form_attribute)
|
||||||
|
can_manage_email = request.user.has_perm(self.email_permission)
|
||||||
|
|
||||||
|
class PermissionAwareForm(base_form):
|
||||||
|
def __init__(self, *form_args, **form_kwargs):
|
||||||
|
super().__init__(*form_args, **form_kwargs)
|
||||||
|
if not can_manage_email and "email_notifications" in self.fields:
|
||||||
|
field = self.fields["email_notifications"]
|
||||||
|
field.disabled = True
|
||||||
|
field.help_text = _(
|
||||||
|
"Only administrators or explicitly authorized users can change this setting."
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(self, form_attribute, PermissionAwareForm)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(SoftwareLicense, "list", path="", detail=False)
|
@register_model_view(SoftwareLicense, "list", path="", detail=False)
|
||||||
class SoftwareLicenseListView(generic.ObjectListView):
|
class SoftwareLicenseListView(generic.ObjectListView):
|
||||||
queryset = SoftwareLicense.objects.all()
|
queryset = SoftwareLicense.objects.all()
|
||||||
@@ -19,7 +44,7 @@ class SoftwareLicenseView(generic.ObjectView):
|
|||||||
|
|
||||||
@register_model_view(SoftwareLicense, "add", detail=False)
|
@register_model_view(SoftwareLicense, "add", detail=False)
|
||||||
@register_model_view(SoftwareLicense, "edit")
|
@register_model_view(SoftwareLicense, "edit")
|
||||||
class SoftwareLicenseEditView(generic.ObjectEditView):
|
class SoftwareLicenseEditView(EmailNotificationPermissionMixin, generic.ObjectEditView):
|
||||||
queryset = SoftwareLicense.objects.all()
|
queryset = SoftwareLicense.objects.all()
|
||||||
form = forms.SoftwareLicenseForm
|
form = forms.SoftwareLicenseForm
|
||||||
|
|
||||||
@@ -30,13 +55,13 @@ class SoftwareLicenseDeleteView(generic.ObjectDeleteView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(SoftwareLicense, "bulk_import", detail=False)
|
@register_model_view(SoftwareLicense, "bulk_import", detail=False)
|
||||||
class SoftwareLicenseBulkImportView(generic.BulkImportView):
|
class SoftwareLicenseBulkImportView(EmailNotificationPermissionMixin, generic.BulkImportView):
|
||||||
queryset = SoftwareLicense.objects.all()
|
queryset = SoftwareLicense.objects.all()
|
||||||
model_form = forms.SoftwareLicenseBulkImportForm
|
model_form = forms.SoftwareLicenseBulkImportForm
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
|
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
|
||||||
class SoftwareLicenseBulkEditView(generic.BulkEditView):
|
class SoftwareLicenseBulkEditView(EmailNotificationPermissionMixin, generic.BulkEditView):
|
||||||
queryset = SoftwareLicense.objects.all()
|
queryset = SoftwareLicense.objects.all()
|
||||||
filterset = filtersets.SoftwareLicenseFilterSet
|
filterset = filtersets.SoftwareLicenseFilterSet
|
||||||
table = tables.SoftwareLicenseTable
|
table = tables.SoftwareLicenseTable
|
||||||
|
|||||||
Reference in New Issue
Block a user