Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion netbox_lifecycle/api/_serializers/contract.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers

from dcim.api.serializers_.devices import DeviceSerializer
from dcim.api.serializers_.devices import DeviceSerializer, ModuleSerializer
from dcim.api.serializers_.manufacturers import ManufacturerSerializer
from netbox.api.serializers import NetBoxModelSerializer
from netbox_lifecycle.api._serializers.license import LicenseAssignmentSerializer
Expand Down Expand Up @@ -87,6 +87,7 @@ class SupportContractAssignmentSerializer(NetBoxModelSerializer):
contract = SupportContractSerializer(nested=True)
sku = SupportSKUSerializer(nested=True, required=False, allow_null=True)
device = DeviceSerializer(nested=True, required=False, allow_null=True)
module = ModuleSerializer(nested=True, required=False, allow_null=True)
license = LicenseAssignmentSerializer(nested=True, required=False, allow_null=True)

class Meta:
Expand All @@ -98,6 +99,7 @@ class Meta:
'contract',
'sku',
'device',
'module',
'license',
'end',
'description',
Expand All @@ -113,5 +115,6 @@ class Meta:
'contract',
'sku',
'device',
'module',
'license',
)
15 changes: 14 additions & 1 deletion netbox_lifecycle/filtersets/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.db.models import Q
from django.utils.translation import gettext as _

from dcim.models import Manufacturer, Device
from dcim.models import Manufacturer, Device, Module
from netbox.filtersets import NetBoxModelFilterSet
from netbox_lifecycle.models import (
Vendor,
Expand Down Expand Up @@ -127,6 +127,17 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet):
to_field_name='name',
label=_('Device (name)'),
)
module_id = django_filters.ModelMultipleChoiceFilter(
field_name='module',
queryset=Module.objects.all(),
label=_('Module (ID)'),
)
module = django_filters.ModelMultipleChoiceFilter(
field_name='module__serial',
queryset=Module.objects.all(),
to_field_name='serial',
label=_('Module (serial)'),
)
license_id = django_filters.ModelMultipleChoiceFilter(
field_name='license__license',
queryset=License.objects.all(),
Expand Down Expand Up @@ -160,6 +171,8 @@ def search(self, queryset, name, value):
| Q(contract__vendor__name__icontains=value)
| Q(sku__sku__icontains=value)
| Q(device__name__icontains=value)
| Q(module__serial__icontains=value)
| Q(module__module_type__model__icontains=value)
| Q(license__device__name__icontains=value)
| Q(license__license__name__icontains=value)
)
Expand Down
14 changes: 12 additions & 2 deletions netbox_lifecycle/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.forms import DateField

from dcim.choices import DeviceStatusChoices
from dcim.models import Device, Manufacturer
from dcim.models import Device, Manufacturer, Module
from netbox.forms import NetBoxModelFilterSetForm
from netbox_lifecycle.models import (
HardwareLifecycle,
Expand Down Expand Up @@ -135,7 +135,12 @@ class SupportContractAssignmentFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'contract_id', 'device_id', 'license_id', 'device_status', name='Assignment'
'contract_id',
'device_id',
'module_id',
'license_id',
'device_status',
name='Assignment',
),
)
contract_id = DynamicModelMultipleChoiceField(
Expand All @@ -156,6 +161,11 @@ class SupportContractAssignmentFilterForm(NetBoxModelFilterSetForm):
selector=True,
label=_('Devices'),
)
module_id = DynamicModelMultipleChoiceField(
queryset=Module.objects.all(),
required=False,
label=_('Module'),
)
device_status = forms.MultipleChoiceField(
label=_('Status'), choices=DeviceStatusChoices, required=False
)
Expand Down
82 changes: 58 additions & 24 deletions netbox_lifecycle/forms/model_forms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _

from dcim.models import DeviceType, ModuleType, Manufacturer, Device
from dcim.models import DeviceType, ModuleType, Manufacturer, Device, Module
from netbox.forms import NetBoxModelForm
from netbox_lifecycle.models import (
HardwareLifecycle,
Expand Down Expand Up @@ -101,19 +101,40 @@ class SupportContractAssignmentForm(NetBoxModelForm):
selector=True,
label=_('Device'),
)
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
selector=True,
label=_('Module'),
query_params={'device_id': '$device'},
)
license = DynamicModelChoiceField(
queryset=LicenseAssignment.objects.all(),
required=False,
selector=True,
label=_('License Assignment'),
)

fieldsets = (
FieldSet('contract', 'sku', name=_('Contract')),
FieldSet(
TabbedGroups(
FieldSet('device', 'module', name=_('Hardware')),
FieldSet('license', name=_('License')),
),
name=_('Assignment'),
),
FieldSet('end', name=_('Dates')),
FieldSet('description', 'comments', 'tags', name=_('Other')),
)

class Meta:
model = SupportContractAssignment
fields = (
'contract',
'sku',
'device',
'module',
'license',
'end',
'description',
Expand All @@ -124,37 +145,50 @@ class Meta:
'end': DatePicker(),
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def clean(self):
super().clean()

# Handle object assignment
selected_objects = [
field for field in ('device', 'license') if self.cleaned_data[field]
]
has_hardware = self.cleaned_data.get('device') or self.cleaned_data.get(
'module'
)
has_license = self.cleaned_data.get('license')

if len(selected_objects) == 0:
# Must select at least one assignment target
if not has_hardware and not has_license:
raise forms.ValidationError(
{
'device': "You must select at least a device or license",
'license': "You must select at least a device or license",
}
_('Select a device, module, or license assignment')
)

# Auto-populate device from module if module selected without device
if self.cleaned_data.get('module') and not self.cleaned_data.get('device'):
self.cleaned_data['device'] = self.cleaned_data['module'].device

# Validate device matches module.device
if (
self.cleaned_data.get('device')
and self.cleaned_data.get('module')
and self.cleaned_data['device'] != self.cleaned_data['module'].device
):
raise forms.ValidationError(
{'module': _('Module must belong to the selected device')}
)

# Auto-populate device from license if license selected without device
if self.cleaned_data.get('license') and not self.cleaned_data.get('device'):
self.cleaned_data['device'] = self.cleaned_data.get('license').device

if self.cleaned_data.get('license') and self.cleaned_data.get('device'):
if self.cleaned_data.get('license').device != self.cleaned_data.get(
'device'
):
raise forms.ValidationError(
{
'device': 'Device assigned to license must match device assignment'
}
)
self.cleaned_data['device'] = self.cleaned_data['license'].device

# Validate device matches license.device if both are set
if (
self.cleaned_data.get('license')
and self.cleaned_data.get('device')
and self.cleaned_data['license'].device
and self.cleaned_data['device'] != self.cleaned_data['license'].device
):
raise forms.ValidationError(
{'device': _('Device must match the device assigned to the license')}
)

return self.cleaned_data


class LicenseForm(NetBoxModelForm):
Expand Down
1 change: 1 addition & 0 deletions netbox_lifecycle/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class SupportContractAssignmentType(NetBoxObjectType):
contract: SupportContractType
sku: SupportSKUType | None
device: DeviceType | None
module: ModuleType | None
license: LicenseType | None
end: str | None

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated manually for feature/85-120-module-assignment

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('dcim', '0185_gfk_indexes'),
('netbox_lifecycle', '0014_rename_last_contract_date_and_more'),
]

operations = [
migrations.AddField(
model_name='supportcontractassignment',
name='module',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='contracts',
to='dcim.module',
),
),
migrations.AlterModelOptions(
name='supportcontractassignment',
options={'ordering': ['contract', 'device', 'module', 'license']},
),
]
80 changes: 45 additions & 35 deletions netbox_lifecycle/models/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ class SupportContractAssignment(PrimaryModel):
blank=True,
related_name='contracts',
)
module = models.ForeignKey(
to='dcim.Module',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='contracts',
)
license = models.ForeignKey(
to='netbox_lifecycle.LicenseAssignment',
on_delete=models.SET_NULL,
Expand All @@ -141,23 +148,29 @@ class SupportContractAssignment(PrimaryModel):
clone_fields = (
'contract',
'sku',
'module',
'end',
)
prerequisite_models = (
'netbox_lifecycle.SupportContract',
'netbox_lifecycle.SupportSKU',
'netbox_lifecycle.License',
'dcim.Device',
'dcim.Module',
)

class Meta:
ordering = ['contract', 'device', 'license']
ordering = ['contract', 'device', 'module', 'license']
constraints = ()

def __str__(self):
if self.module:
return f'{self.module}: {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}'
if self.device:
return f'{self.device}: {self.contract.contract_id}'
return f'{self.contract.contract_id}'

def get_absolute_url(self):
return reverse(
Expand All @@ -176,45 +189,42 @@ def get_device_status_color(self):
return DeviceStatusChoices.colors.get(self.device.status)

def clean(self):
has_hardware = self.device or self.module
has_license = self.license

# Must select something
if not has_hardware and not has_license:
raise ValidationError(_('Select a device, module, or license assignment'))

# If both device and module, they must match
if self.device and self.module and self.device != self.module.device:
raise ValidationError(
{'module': _('Module must belong to the selected device')}
)

# If license has a device, it must match the assignment's device
if self.license and self.license.device and self.device:
if self.device != self.license.device:
raise ValidationError(
{
'device': _(
'Device must match the device assigned to the license'
)
}
)

# Uniqueness check: contract + device + module + license + sku must be unique
if (
self.device
and self.license
and SupportContractAssignment.objects.filter(
SupportContractAssignment.objects.filter(
contract=self.contract,
device=self.device,
module=self.module,
license=self.license,
sku=self.sku,
)
.exclude(pk=self.pk)
.count()
> 0
):
raise ValidationError('Device or License must be unique')
elif (
self.device
and not self.license
and SupportContractAssignment.objects.filter(
contract=self.contract,
device=self.device,
sku=self.sku,
license=self.license,
)
.exclude(pk=self.pk)
.count()
> 0
.exists()
):
raise ValidationError('Device must be unique')
elif (
not self.device
and self.license
and SupportContractAssignment.objects.filter(
contract=self.contract,
device=self.device,
license=self.license,
sku=self.sku,
raise ValidationError(
_('This assignment combination already exists for this contract')
)
.exclude(pk=self.pk)
.count()
> 0
):
raise ValidationError('License must be unique')
Loading