Skip to content

Commit b0bdaec

Browse files
authored
Closes #85, #120: Add module support to SupportContractAssignment (#125)
2 parents 0ee4d64 + 1871b54 commit b0bdaec

12 files changed

Lines changed: 246 additions & 121 deletions

File tree

netbox_lifecycle/api/_serializers/contract.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from rest_framework import serializers
22

3-
from dcim.api.serializers_.devices import DeviceSerializer
3+
from dcim.api.serializers_.devices import DeviceSerializer, ModuleSerializer
44
from dcim.api.serializers_.manufacturers import ManufacturerSerializer
55
from netbox.api.serializers import NetBoxModelSerializer
66
from netbox_lifecycle.api._serializers.license import LicenseAssignmentSerializer
@@ -87,6 +87,7 @@ class SupportContractAssignmentSerializer(NetBoxModelSerializer):
8787
contract = SupportContractSerializer(nested=True)
8888
sku = SupportSKUSerializer(nested=True, required=False, allow_null=True)
8989
device = DeviceSerializer(nested=True, required=False, allow_null=True)
90+
module = ModuleSerializer(nested=True, required=False, allow_null=True)
9091
license = LicenseAssignmentSerializer(nested=True, required=False, allow_null=True)
9192

9293
class Meta:
@@ -98,6 +99,7 @@ class Meta:
9899
'contract',
99100
'sku',
100101
'device',
102+
'module',
101103
'license',
102104
'end',
103105
'description',
@@ -113,5 +115,6 @@ class Meta:
113115
'contract',
114116
'sku',
115117
'device',
118+
'module',
116119
'license',
117120
)

netbox_lifecycle/filtersets/contract.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.db.models import Q
33
from django.utils.translation import gettext as _
44

5-
from dcim.models import Manufacturer, Device
5+
from dcim.models import Manufacturer, Device, Module
66
from netbox.filtersets import NetBoxModelFilterSet
77
from netbox_lifecycle.models import (
88
Vendor,
@@ -127,6 +127,17 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet):
127127
to_field_name='name',
128128
label=_('Device (name)'),
129129
)
130+
module_id = django_filters.ModelMultipleChoiceFilter(
131+
field_name='module',
132+
queryset=Module.objects.all(),
133+
label=_('Module (ID)'),
134+
)
135+
module = django_filters.ModelMultipleChoiceFilter(
136+
field_name='module__serial',
137+
queryset=Module.objects.all(),
138+
to_field_name='serial',
139+
label=_('Module (serial)'),
140+
)
130141
license_id = django_filters.ModelMultipleChoiceFilter(
131142
field_name='license__license',
132143
queryset=License.objects.all(),
@@ -160,6 +171,8 @@ def search(self, queryset, name, value):
160171
| Q(contract__vendor__name__icontains=value)
161172
| Q(sku__sku__icontains=value)
162173
| Q(device__name__icontains=value)
174+
| Q(module__serial__icontains=value)
175+
| Q(module__module_type__model__icontains=value)
163176
| Q(license__device__name__icontains=value)
164177
| Q(license__license__name__icontains=value)
165178
)

netbox_lifecycle/forms/filtersets.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.forms import DateField
66

77
from dcim.choices import DeviceStatusChoices
8-
from dcim.models import Device, Manufacturer
8+
from dcim.models import Device, Manufacturer, Module
99
from netbox.forms import NetBoxModelFilterSetForm
1010
from netbox_lifecycle.models import (
1111
HardwareLifecycle,
@@ -135,7 +135,12 @@ class SupportContractAssignmentFilterForm(NetBoxModelFilterSetForm):
135135
fieldsets = (
136136
FieldSet('q', 'filter_id', 'tag'),
137137
FieldSet(
138-
'contract_id', 'device_id', 'license_id', 'device_status', name='Assignment'
138+
'contract_id',
139+
'device_id',
140+
'module_id',
141+
'license_id',
142+
'device_status',
143+
name='Assignment',
139144
),
140145
)
141146
contract_id = DynamicModelMultipleChoiceField(
@@ -156,6 +161,11 @@ class SupportContractAssignmentFilterForm(NetBoxModelFilterSetForm):
156161
selector=True,
157162
label=_('Devices'),
158163
)
164+
module_id = DynamicModelMultipleChoiceField(
165+
queryset=Module.objects.all(),
166+
required=False,
167+
label=_('Module'),
168+
)
159169
device_status = forms.MultipleChoiceField(
160170
label=_('Status'), choices=DeviceStatusChoices, required=False
161171
)

netbox_lifecycle/forms/model_forms.py

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django import forms
22
from django.utils.translation import gettext as _
33

4-
from dcim.models import DeviceType, ModuleType, Manufacturer, Device
4+
from dcim.models import DeviceType, ModuleType, Manufacturer, Device, Module
55
from netbox.forms import NetBoxModelForm
66
from netbox_lifecycle.models import (
77
HardwareLifecycle,
@@ -101,19 +101,40 @@ class SupportContractAssignmentForm(NetBoxModelForm):
101101
selector=True,
102102
label=_('Device'),
103103
)
104+
module = DynamicModelChoiceField(
105+
queryset=Module.objects.all(),
106+
required=False,
107+
selector=True,
108+
label=_('Module'),
109+
query_params={'device_id': '$device'},
110+
)
104111
license = DynamicModelChoiceField(
105112
queryset=LicenseAssignment.objects.all(),
106113
required=False,
107114
selector=True,
108115
label=_('License Assignment'),
109116
)
110117

118+
fieldsets = (
119+
FieldSet('contract', 'sku', name=_('Contract')),
120+
FieldSet(
121+
TabbedGroups(
122+
FieldSet('device', 'module', name=_('Hardware')),
123+
FieldSet('license', name=_('License')),
124+
),
125+
name=_('Assignment'),
126+
),
127+
FieldSet('end', name=_('Dates')),
128+
FieldSet('description', 'comments', 'tags', name=_('Other')),
129+
)
130+
111131
class Meta:
112132
model = SupportContractAssignment
113133
fields = (
114134
'contract',
115135
'sku',
116136
'device',
137+
'module',
117138
'license',
118139
'end',
119140
'description',
@@ -124,37 +145,50 @@ class Meta:
124145
'end': DatePicker(),
125146
}
126147

127-
def __init__(self, *args, **kwargs):
128-
super().__init__(*args, **kwargs)
129-
130148
def clean(self):
131149
super().clean()
132150

133-
# Handle object assignment
134-
selected_objects = [
135-
field for field in ('device', 'license') if self.cleaned_data[field]
136-
]
151+
has_hardware = self.cleaned_data.get('device') or self.cleaned_data.get(
152+
'module'
153+
)
154+
has_license = self.cleaned_data.get('license')
137155

138-
if len(selected_objects) == 0:
156+
# Must select at least one assignment target
157+
if not has_hardware and not has_license:
139158
raise forms.ValidationError(
140-
{
141-
'device': "You must select at least a device or license",
142-
'license': "You must select at least a device or license",
143-
}
159+
_('Select a device, module, or license assignment')
144160
)
145161

162+
# Auto-populate device from module if module selected without device
163+
if self.cleaned_data.get('module') and not self.cleaned_data.get('device'):
164+
self.cleaned_data['device'] = self.cleaned_data['module'].device
165+
166+
# Validate device matches module.device
167+
if (
168+
self.cleaned_data.get('device')
169+
and self.cleaned_data.get('module')
170+
and self.cleaned_data['device'] != self.cleaned_data['module'].device
171+
):
172+
raise forms.ValidationError(
173+
{'module': _('Module must belong to the selected device')}
174+
)
175+
176+
# Auto-populate device from license if license selected without device
146177
if self.cleaned_data.get('license') and not self.cleaned_data.get('device'):
147-
self.cleaned_data['device'] = self.cleaned_data.get('license').device
148-
149-
if self.cleaned_data.get('license') and self.cleaned_data.get('device'):
150-
if self.cleaned_data.get('license').device != self.cleaned_data.get(
151-
'device'
152-
):
153-
raise forms.ValidationError(
154-
{
155-
'device': 'Device assigned to license must match device assignment'
156-
}
157-
)
178+
self.cleaned_data['device'] = self.cleaned_data['license'].device
179+
180+
# Validate device matches license.device if both are set
181+
if (
182+
self.cleaned_data.get('license')
183+
and self.cleaned_data.get('device')
184+
and self.cleaned_data['license'].device
185+
and self.cleaned_data['device'] != self.cleaned_data['license'].device
186+
):
187+
raise forms.ValidationError(
188+
{'device': _('Device must match the device assigned to the license')}
189+
)
190+
191+
return self.cleaned_data
158192

159193

160194
class LicenseForm(NetBoxModelForm):

netbox_lifecycle/graphql/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class SupportContractAssignmentType(NetBoxObjectType):
6666
contract: SupportContractType
6767
sku: SupportSKUType | None
6868
device: DeviceType | None
69+
module: ModuleType | None
6970
license: LicenseType | None
7071
end: str | None
7172

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated manually for feature/85-120-module-assignment
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('dcim', '0185_gfk_indexes'),
11+
('netbox_lifecycle', '0014_rename_last_contract_date_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='supportcontractassignment',
17+
name='module',
18+
field=models.ForeignKey(
19+
blank=True,
20+
null=True,
21+
on_delete=django.db.models.deletion.SET_NULL,
22+
related_name='contracts',
23+
to='dcim.module',
24+
),
25+
),
26+
migrations.AlterModelOptions(
27+
name='supportcontractassignment',
28+
options={'ordering': ['contract', 'device', 'module', 'license']},
29+
),
30+
]

netbox_lifecycle/models/contract.py

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ class SupportContractAssignment(PrimaryModel):
124124
blank=True,
125125
related_name='contracts',
126126
)
127+
module = models.ForeignKey(
128+
to='dcim.Module',
129+
on_delete=models.SET_NULL,
130+
null=True,
131+
blank=True,
132+
related_name='contracts',
133+
)
127134
license = models.ForeignKey(
128135
to='netbox_lifecycle.LicenseAssignment',
129136
on_delete=models.SET_NULL,
@@ -141,23 +148,29 @@ class SupportContractAssignment(PrimaryModel):
141148
clone_fields = (
142149
'contract',
143150
'sku',
151+
'module',
144152
'end',
145153
)
146154
prerequisite_models = (
147155
'netbox_lifecycle.SupportContract',
148156
'netbox_lifecycle.SupportSKU',
149157
'netbox_lifecycle.License',
150158
'dcim.Device',
159+
'dcim.Module',
151160
)
152161

153162
class Meta:
154-
ordering = ['contract', 'device', 'license']
163+
ordering = ['contract', 'device', 'module', 'license']
155164
constraints = ()
156165

157166
def __str__(self):
167+
if self.module:
168+
return f'{self.module}: {self.contract.contract_id}'
158169
if self.license and self.device:
159170
return f'{self.device} ({self.license}): {self.contract.contract_id}'
160-
return f'{self.device}: {self.contract.contract_id}'
171+
if self.device:
172+
return f'{self.device}: {self.contract.contract_id}'
173+
return f'{self.contract.contract_id}'
161174

162175
def get_absolute_url(self):
163176
return reverse(
@@ -176,45 +189,42 @@ def get_device_status_color(self):
176189
return DeviceStatusChoices.colors.get(self.device.status)
177190

178191
def clean(self):
192+
has_hardware = self.device or self.module
193+
has_license = self.license
194+
195+
# Must select something
196+
if not has_hardware and not has_license:
197+
raise ValidationError(_('Select a device, module, or license assignment'))
198+
199+
# If both device and module, they must match
200+
if self.device and self.module and self.device != self.module.device:
201+
raise ValidationError(
202+
{'module': _('Module must belong to the selected device')}
203+
)
204+
205+
# If license has a device, it must match the assignment's device
206+
if self.license and self.license.device and self.device:
207+
if self.device != self.license.device:
208+
raise ValidationError(
209+
{
210+
'device': _(
211+
'Device must match the device assigned to the license'
212+
)
213+
}
214+
)
215+
216+
# Uniqueness check: contract + device + module + license + sku must be unique
179217
if (
180-
self.device
181-
and self.license
182-
and SupportContractAssignment.objects.filter(
218+
SupportContractAssignment.objects.filter(
183219
contract=self.contract,
184220
device=self.device,
221+
module=self.module,
185222
license=self.license,
186223
sku=self.sku,
187224
)
188225
.exclude(pk=self.pk)
189-
.count()
190-
> 0
191-
):
192-
raise ValidationError('Device or License must be unique')
193-
elif (
194-
self.device
195-
and not self.license
196-
and SupportContractAssignment.objects.filter(
197-
contract=self.contract,
198-
device=self.device,
199-
sku=self.sku,
200-
license=self.license,
201-
)
202-
.exclude(pk=self.pk)
203-
.count()
204-
> 0
226+
.exists()
205227
):
206-
raise ValidationError('Device must be unique')
207-
elif (
208-
not self.device
209-
and self.license
210-
and SupportContractAssignment.objects.filter(
211-
contract=self.contract,
212-
device=self.device,
213-
license=self.license,
214-
sku=self.sku,
228+
raise ValidationError(
229+
_('This assignment combination already exists for this contract')
215230
)
216-
.exclude(pk=self.pk)
217-
.count()
218-
> 0
219-
):
220-
raise ValidationError('License must be unique')

0 commit comments

Comments
 (0)