From 68326c70e13f7e1b9f30a550d9a1f8cb1bdd1594 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 19 Mar 2024 20:14:59 -0500 Subject: [PATCH 1/3] Adjust contracts assignment model --- netbox_lifecycle/models/contract.py | 43 ++++++++++------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/netbox_lifecycle/models/contract.py b/netbox_lifecycle/models/contract.py index d14d6cc..65eafb8 100644 --- a/netbox_lifecycle/models/contract.py +++ b/netbox_lifecycle/models/contract.py @@ -124,22 +124,19 @@ class SupportContractAssignment(NetBoxModel): blank=True, related_name='assignments', ) - - assigned_object_type = models.ForeignKey( - to=ContentType, - limit_choices_to=('dcim.Device', 'netbox_lifecycle.LicenseAssignment'), - on_delete=models.PROTECT, - related_name='+', + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.SET_NULL, + null=True, blank=True, - null=True + related_name='contracts', ) - assigned_object_id = models.PositiveBigIntegerField( + license = models.ForeignKey( + to='netbox_lifecycle.LicenseAssignment', + on_delete=models.SET_NULL, + null=True, blank=True, - null=True - ) - assigned_object = GenericForeignKey( - ct_field='assigned_object_type', - fk_field='assigned_object_id' + related_name='contracts', ) end = models.DateField( null=True, @@ -159,23 +156,13 @@ class SupportContractAssignment(NetBoxModel): ) class Meta: - ordering = ['contract', 'assigned_object_type', 'assigned_object_id'] - constraints = ( - models.UniqueConstraint( - 'contract', 'sku', 'assigned_object_type', 'assigned_object_id', - name='%(app_label)s_%(class)s_unique_assignments', - violation_error_message="Contract assignments must be unique." - ), - models.UniqueConstraint( - 'contract', 'assigned_object_type', 'assigned_object_id', - name='%(app_label)s_%(class)s_unique_assignment_null_sku', - condition=Q(sku__isnull=True), - violation_error_message="Contract assignments to assigned_objects must be unique." - ), - ) + ordering = ['contract', 'device', 'license'] + constraints = () def __str__(self): - return f'{self.assigned_object}: {self.contract.contract_id}' + if self.license and self.device: + return f'{self.device} ({self.license}): {self.contract.contract_id}' + return f'{self.device}: {self.contract.contract_id}' def get_absolute_url(self): return reverse('plugins:netbox_lifecycle:supportcontract_assignments', args=[self.contract.pk]) From 2352445e6ada7bfe8803d2af9602f77b61ca4c6e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 19 Mar 2024 20:16:19 -0500 Subject: [PATCH 2/3] Adjust all other classes for contracts model change --- netbox_lifecycle/__init__.py | 8 +- .../api/nested_serializers/contract.py | 21 +---- netbox_lifecycle/api/serializers/contract.py | 27 ++----- netbox_lifecycle/filtersets/contract.py | 45 ++++------- netbox_lifecycle/forms/model_forms.py | 24 ++---- .../0011_alter_supportcontractassignment.py | 81 +++++++++++++++++++ netbox_lifecycle/models/contract.py | 6 +- netbox_lifecycle/models/license.py | 8 -- netbox_lifecycle/tables/contract.py | 36 ++++----- 9 files changed, 126 insertions(+), 130 deletions(-) create mode 100644 netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py diff --git a/netbox_lifecycle/__init__.py b/netbox_lifecycle/__init__.py index 2c994d1..35b17a2 100644 --- a/netbox_lifecycle/__init__.py +++ b/netbox_lifecycle/__init__.py @@ -27,22 +27,18 @@ def ready(self): from netbox_lifecycle.models import SupportContractAssignment, HardwareLifecycle # Add Generic Relations to appropriate models - GenericRelation( - to=SupportContractAssignment, - content_type_field='assigned_object_type', - object_id_field='assigned_object_id', - related_query_name='device' - ).contribute_to_class(Device, 'contracts') GenericRelation( to=HardwareLifecycle, content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_name='device_type', related_query_name='device_type' ).contribute_to_class(DeviceType, 'hardware_lifecycle') GenericRelation( to=HardwareLifecycle, content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_name='module_type', related_query_name='module_type' ).contribute_to_class(ModuleType, 'hardware_lifecycle') diff --git a/netbox_lifecycle/api/nested_serializers/contract.py b/netbox_lifecycle/api/nested_serializers/contract.py index b44126b..91fd771 100644 --- a/netbox_lifecycle/api/nested_serializers/contract.py +++ b/netbox_lifecycle/api/nested_serializers/contract.py @@ -1,11 +1,7 @@ -from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from dcim.api.nested_serializers import NestedManufacturerSerializer, NestedDeviceSerializer -from netbox.api.fields import ContentTypeField +from dcim.api.nested_serializers import NestedManufacturerSerializer from netbox.api.serializers import WritableNestedSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox_lifecycle.models import Vendor, SupportContract, SupportContractAssignment, SupportSKU __all__ = ( @@ -15,8 +11,6 @@ 'NestedSupportContractAssignmentSerializer', ) -from utilities.api import get_serializer_for_model - class NestedVendorSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_lifecycle-api:hardwarelifecycle-detail') @@ -48,17 +42,6 @@ class NestedSupportContractAssignmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_lifecycle-api:licenseassignment-detail') contract = NestedSupportContractSerializer() - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - class Meta: model = SupportContractAssignment - fields = ('url', 'id', 'display', 'contract', 'assigned_object_type', 'assigned_object_id', 'assigned_object') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, context=context).data + fields = ('url', 'id', 'display', 'contract', 'device', 'license') diff --git a/netbox_lifecycle/api/serializers/contract.py b/netbox_lifecycle/api/serializers/contract.py index b1c4c72..fe25ebd 100644 --- a/netbox_lifecycle/api/serializers/contract.py +++ b/netbox_lifecycle/api/serializers/contract.py @@ -1,12 +1,9 @@ -from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.api.nested_serializers import NestedManufacturerSerializer, NestedDeviceSerializer -from netbox.api.fields import ContentTypeField from netbox.api.serializers import NetBoxModelSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX -from netbox_lifecycle.api.nested_serializers import NestedVendorSerializer, NestedSupportContractSerializer +from netbox_lifecycle.api.nested_serializers import NestedVendorSerializer, NestedSupportContractSerializer, \ + NestedLicenseAssignmentSerializer from netbox_lifecycle.models import Vendor, SupportContract, SupportContractAssignment, SupportSKU __all__ = ( @@ -52,25 +49,11 @@ class SupportContractAssignmentSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_lifecycle-api:licenseassignment-detail') contract = NestedSupportContractSerializer() - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) + device = NestedDeviceSerializer() + license = NestedLicenseAssignmentSerializer() class Meta: model = SupportContractAssignment fields = ( - 'url', 'id', 'display', 'contract', 'assigned_object_type', 'assigned_object_id', - 'assigned_object', 'end' + 'url', 'id', 'display', 'contract', 'device', 'license', 'end' ) - - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, context=context).data diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 08f1367..2c29c15 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -5,7 +5,8 @@ from dcim.models import Manufacturer, Device from netbox.filtersets import NetBoxModelFilterSet -from netbox_lifecycle.models import Vendor, SupportContract, SupportContractAssignment, SupportSKU, LicenseAssignment +from netbox_lifecycle.models import Vendor, SupportContract, SupportContractAssignment, SupportSKU, LicenseAssignment, \ + License __all__ = ( 'SupportContractFilterSet', @@ -87,27 +88,24 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): queryset=SupportContract.objects.all(), label=_('Contract'), ) - assigned_object_type_id = django_filters.ModelMultipleChoiceFilter( - queryset=ContentType.objects.all() - ) - device = MultiValueCharFilter( - method='filter_device', - field_name='name', + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), label=_('Device (name)'), ) - device_id = MultiValueNumberFilter( - method='filter_device', - field_name='pk', + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), label=_('Device (ID)'), ) - license = MultiValueCharFilter( - method='filter_license', - field_name='name', + license = django_filters.ModelMultipleChoiceFilter( + field_name='license__license__name', + queryset=License.objects.all(), label=_('License (SKU)'), ) - license_id = MultiValueNumberFilter( - method='filter_license', - field_name='pk', + license_id = django_filters.ModelMultipleChoiceFilter( + field_name='license', + queryset=LicenseAssignment.objects.all(), label=_('License (ID)'), ) @@ -127,18 +125,3 @@ def search(self, queryset, name, value): Q(license__license__name__icontains=value) ) return queryset.filter(qs_filter).distinct() - - def filter_device(self, queryset, name, value): - licenses = LicenseAssignment.objects.filter(**{'device__{}__in'.format(name): value}) - devices = Device.objects.filter(**{'{}__in'.format(name): value}) - device_ids = devices.values_list('id', flat=True) - license_ids = licenses.values_list('id', flat=True) - - return queryset.filter( - Q(device__in=device_ids) | Q(license__in=license_ids) - ) - - def filter_license(self, queryset, name, value): - return queryset.filter( - license__in=value - ) diff --git a/netbox_lifecycle/forms/model_forms.py b/netbox_lifecycle/forms/model_forms.py index 5d3b010..a222f7c 100644 --- a/netbox_lifecycle/forms/model_forms.py +++ b/netbox_lifecycle/forms/model_forms.py @@ -86,17 +86,6 @@ class Meta: } def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}).copy() - if instance: - if type(instance.assigned_object) is Device: - initial['device'] = instance.assigned_object - elif type(instance.assigned_object) is LicenseAssignment: - initial['license'] = instance.assigned_object - kwargs['initial'] = initial - super().__init__(*args, **kwargs) def clean(self): @@ -107,14 +96,13 @@ def clean(self): field for field in ('device', 'license') if self.cleaned_data[field] ] - if len(selected_objects) > 1: - raise forms.ValidationError({ - selected_objects[1]: "You can only assign a device or license" + if len(selected_objects) == 0: + raise forms.ValidationErrr({ + selected_objects[1]: "You must select at least a device or license" }) - elif selected_objects: - self.instance.assigned_object = self.cleaned_data[selected_objects[0]] - else: - self.instance.assigned_object = None + + if self.cleaned_data.get('license') and not self.cleaned_data.get('device'): + self.cleaned_data['device'] = self.cleaned_data.get('license').device class LicenseForm(NetBoxModelForm): diff --git a/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py b/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py new file mode 100644 index 0000000..4619e18 --- /dev/null +++ b/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.4 on 2024-03-13 04:24 + +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_assigned_object_forward(apps, schema_editor): + SupportContractAssignment = apps.get_model('netbox_lifecycle', 'SupportContractAssignment') + LicenseAssignment = apps.get_model('netbox_lifecycle', 'LicenseAssignment') + Device = apps.get_model('dcim', 'Device') + ContentType = apps.get_model('contenttypes', 'ContentType') + + for assignment in SupportContractAssignment.objects.all(): + if assignment.assigned_object_type == ContentType.objects.get(app_label='dcim', model='device'): + device = Device.objects.get(pk=assignment.assigned_object_id) + assignment.device = device + assignment.save() + else: + license_assignment = LicenseAssignment.objects.get(pk=assignment.assigned_object_id) + assignment.device = license_assignment.device + assignment.license = license_assignment + assignment.save() + +def migrate_assigned_object_reverse(apps, schema_editor): + SupportContractAssignment = apps.get_model('netbox_lifecycle', 'SupportContractAssignment') + ContentType = apps.get_model('contenttypes', 'ContentType') + + device_type = ContentType.objects.get(app_label='dcim', model='device') + license_type = ContentType.objects.get(app_label='netbox_lifecycle', model='licenseassignment') + + for assignment in SupportContractAssignment.objects.all(): + if assignment.license is None: + assignment.assigned_object_type = device_type + assignment.assigned_object_id = assignment.device.pk + assignment.save() + else: + assignment.assigned_object_id = license_type + assignment.assigned_object_id = assignment.license.pk + assignment.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0185_gfk_indexes'), + ('netbox_lifecycle', '0010_licenseassignment_quantity'), + ] + + operations = [ + migrations.AddField( + model_name='supportcontractassignment', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='dcim.device'), + ), + migrations.AddField( + model_name='supportcontractassignment', + name='license', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='netbox_lifecycle.licenseassignment'), + ), + migrations.RunPython(migrate_assigned_object_forward, migrate_assigned_object_reverse), + migrations.AlterModelOptions( + name='supportcontractassignment', + options={'ordering': ['contract', 'device', 'license']}, + ), + migrations.RemoveConstraint( + model_name='supportcontractassignment', + name='netbox_lifecycle_supportcontractassignment_unique_assignments', + ), + migrations.RemoveConstraint( + model_name='supportcontractassignment', + name='netbox_lifecycle_supportcontractassignment_unique_assignment_null_sku', + ), + migrations.RemoveField( + model_name='supportcontractassignment', + name='assigned_object_id', + ), + migrations.RemoveField( + model_name='supportcontractassignment', + name='assigned_object_type', + ), + ] diff --git a/netbox_lifecycle/models/contract.py b/netbox_lifecycle/models/contract.py index 65eafb8..98be075 100644 --- a/netbox_lifecycle/models/contract.py +++ b/netbox_lifecycle/models/contract.py @@ -174,8 +174,6 @@ def end_date(self): return self.contract.end def get_device_status_color(self): - if self.assigned_object is None: + if self.device is None: return - if hasattr(self.assigned_object, 'device'): - return DeviceStatusChoices.colors.get(self.assigned_object.device.status) - return DeviceStatusChoices.colors.get(self.assigned_object.status) + return DeviceStatusChoices.colors.get(self.device.status) diff --git a/netbox_lifecycle/models/license.py b/netbox_lifecycle/models/license.py index 0455144..af15172 100644 --- a/netbox_lifecycle/models/license.py +++ b/netbox_lifecycle/models/license.py @@ -69,14 +69,6 @@ class LicenseAssignment(NetBoxModel): blank=True, ) - contracts = GenericRelation( - to='netbox_lifecycle.SupportContractAssignment', - content_type_field='assigned_object_type', - object_id_field='assigned_object_id', - related_query_name='license' - - ) - clone_fields = ( 'vendor', 'license', ) diff --git a/netbox_lifecycle/tables/contract.py b/netbox_lifecycle/tables/contract.py index 84ab915..cbc5544 100644 --- a/netbox_lifecycle/tables/contract.py +++ b/netbox_lifecycle/tables/contract.py @@ -65,45 +65,37 @@ class SupportContractAssignmentTable(NetBoxTable): verbose_name='SKU', linkify=True, ) - assigned_object_type = tables.Column( - verbose_name=_('Object Type'), - ) - assigned_object = tables.Column( - verbose_name='Assigned Object', - linkify=True, - orderable=False, - ) device_name = tables.Column( verbose_name='Device Name', - accessor='assigned_object__name', + accessor='device__name', linkify=False, - orderable=False, + orderable=True, ) device_serial = tables.Column( verbose_name='Serial Number', - accessor='assigned_object__serial', - orderable=False, + accessor='device__serial', + orderable=True, ) device_model = tables.Column( verbose_name='Device Model', - accessor='assigned_object__device_type__model', + accessor='device__device_type__model', linkify=False, - orderable=False, + orderable=True, ) device_status = ChoiceFieldColumn( verbose_name='Device Status', - accessor='assigned_object__status', - orderable=False, + accessor='device__status', + orderable=True, ) license_name = tables.Column( verbose_name='License', - accessor='assigned_object__license__name', + accessor='license__license__name', linkify=False, - orderable=False, + orderable=True, ) quantity = tables.Column( - verbose_name='Quantity', - accessor='assigned_object__quantity', + verbose_name='License Quantity', + accessor='license__quantity', orderable=False, ) renewal = tables.Column( @@ -119,8 +111,8 @@ class SupportContractAssignmentTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = SupportContractAssignment fields = ( - 'pk', 'contract', 'sku', 'assigned_object_type', 'assigned_object', 'device_name', 'license_name', - 'device_model', 'device_serial', 'quantity', 'renewal', 'end' + 'pk', 'contract', 'sku', 'device_name', 'license_name', 'device_model', 'device_serial', 'quantity', + 'renewal', 'end' ) default_columns = ( 'pk', 'contract', 'sku', 'device_name', 'license_name', 'device_model', 'device_serial' From 613683df5f3a51f7dfa966c5bd5d955a7750ef33 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 19 Mar 2024 20:17:30 -0500 Subject: [PATCH 3/3] Version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 49fbe2d..8047b0d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='netbox-lifecycle', - version='1.0.1', + version='1.0.2', description='NetBox Lifecycle', long_description='NetBox Support Contract and EOL/EOS management', url='https://github.com/dansheps/netbox-lifecycle/',