Skip to content

Commit a271a5a

Browse files
DylanLucciDylanLachTrip
committed
Update to v3.4.0
* Make scan requirement text larger on bulk scan view * Scanning is now tied to edit permissions * hotfix 3.3.4 * Ensure full_clean method utilised before saves * fix bug #22 * form fixes and qol stuff * organise imports for lint * fix duplicate stuff for ruff * Add eol_date to asset serializer * setup for overview page * fix ruff * First draft for overview page plus some language updates for clarity * Add filter forms to bulk assign views * Add delivery_instructions to Purchases * Add delivery_site and storage_site as separate fields * fix the tests * Add logic to conditionally handle updating Asset storage_site and storage_location * thank you isort, very cool * thank you ruff, very cool * Closes #23: fr idea for purchase delivery rework Closes: #23 - Add filters to bulk_assign views and update filtersets to prevent showing Assets that are already assigned to existing models - Add delivery_instructions field to Purchase model - Add storage_site field to Asset model and delivery_site field to Delivery model, allow for Assets to be assigned to a site without a location * overview revision 2 * overview updates * make the image have the pointer, not the container * Add what's next section * Filter Bulk Assign querysets to only show unassigned models --------- Co-authored-by: Dylan <dylan@sol1.com.au> Co-authored-by: Lachlan Tripolone <lachlan@sol1.com.au>
1 parent e387d76 commit a271a5a

30 files changed

Lines changed: 488 additions & 62 deletions

netbox_inventory/api/serializers_/assets.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ModuleTypeSerializer,
1111
RackSerializer,
1212
RackTypeSerializer,
13+
SiteSerializer,
1314
)
1415
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
1516
from tenancy.api.serializers import ContactSerializer, TenantSerializer
@@ -142,6 +143,12 @@ class AssetSerializer(NetBoxModelSerializer):
142143
allow_null=True,
143144
default=None,
144145
)
146+
storage_site = SiteSerializer(
147+
nested=True,
148+
required=False,
149+
allow_null=True,
150+
default=None,
151+
)
145152
storage_location = LocationSerializer(
146153
nested=True,
147154
required=False,
@@ -224,6 +231,7 @@ class Meta:
224231
'rack',
225232
'tenant',
226233
'contact',
234+
'storage_site',
227235
'storage_location',
228236
'owner',
229237
'bom',

netbox_inventory/api/serializers_/deliveries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class Meta:
9292
'status',
9393
'date',
9494
'description',
95+
'delivery_instructions',
9596
'comments',
9697
'tags',
9798
'custom_fields',
@@ -110,6 +111,7 @@ class Meta:
110111
"status",
111112
"date",
112113
"description",
114+
"delivery_instructions",
113115
)
114116

115117

netbox_inventory/api/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class AssetViewSet(NetBoxModelViewSet):
4848
'module',
4949
'rack_type',
5050
'rack',
51+
'storage_site',
5152
'storage_location',
5253
'delivery',
5354
'purchase__supplier',

netbox_inventory/filtersets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ class AssetFilterSet(NetBoxModelFilterSet):
367367
)
368368
storage_site_id = django_filters.ModelMultipleChoiceFilter(
369369
queryset=Site.objects.all(),
370-
field_name="storage_location__site",
370+
field_name="storage_site",
371371
label="Storage site (ID)",
372372
)
373373
storage_location_id = django_filters.ModelMultipleChoiceFilter(
@@ -632,6 +632,7 @@ def search(self, queryset, name, value):
632632
| Q(description__icontains=value)
633633
| Q(supplier__name__icontains=value)
634634
| Q(boms__name__icontains=value)
635+
| Q(delivery_instructions__icontains=value)
635636
)
636637
return queryset.filter(query)
637638

netbox_inventory/forms/bulk.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ class AssetBulkEditForm(NetBoxModelBulkEditForm):
176176
"group_id": "$contact_group",
177177
},
178178
)
179+
storage_site = DynamicModelChoiceField(
180+
queryset=Site.objects.all(),
181+
help_text=Asset._meta.get_field("storage_site").help_text,
182+
required=False,
183+
)
179184
storage_location = DynamicModelChoiceField(
180185
queryset=Location.objects.all(),
181186
help_text=Asset._meta.get_field("storage_location").help_text,
@@ -209,7 +214,7 @@ class AssetBulkEditForm(NetBoxModelBulkEditForm):
209214
name='Purchase',
210215
),
211216
FieldSet("tenant", "contact_group", "contact", name="Assigned to"),
212-
FieldSet("storage_location", name="Location"),
217+
FieldSet("storage_site", "storage_location", name="Location"),
213218
)
214219
nullable_fields = (
215220
"name",
@@ -289,7 +294,7 @@ class AssetImportForm(NetBoxModelImportForm):
289294
storage_site = CSVModelChoiceField(
290295
queryset=Site.objects.all(),
291296
to_field_name="name",
292-
help_text="Site that contains storage_location asset will be stored in.",
297+
help_text="Site where is this asset stored when not in use. It must exist before import.",
293298
required=False,
294299
)
295300
storage_location = CSVModelChoiceField(

netbox_inventory/forms/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from tenancy.models import Contact, ContactGroup, Tenant
44
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
55
from utilities.forms.rendering import FieldSet
6-
from utilities.forms.widgets import DatePicker
6+
from utilities.forms.widgets import DatePicker, MarkdownWidget
77

88
from netbox_inventory.choices import HardwareKindChoices
99

@@ -125,6 +125,7 @@ class AssetForm(NetBoxModelForm):
125125
)
126126
storage_site = DynamicModelChoiceField(
127127
queryset=Site.objects.all(),
128+
help_text=Asset._meta.get_field("storage_site").help_text,
128129
required=False,
129130
initial_params={
130131
"locations": "$storage_location",
@@ -178,6 +179,7 @@ class Meta:
178179
'module_type',
179180
'inventoryitem_type',
180181
'rack_type',
182+
'storage_site',
181183
'storage_location',
182184
'owner',
183185
'bom',
@@ -302,6 +304,7 @@ class PurchaseForm(NetBoxModelForm):
302304
'date',
303305
'description',
304306
'tags',
307+
'delivery_instructions',
305308
name='Purchase',
306309
),
307310
)
@@ -315,11 +318,13 @@ class Meta:
315318
"status",
316319
"date",
317320
"description",
321+
"delivery_instructions",
318322
"comments",
319323
"tags",
320324
)
321325
widgets = {
322326
"date": DatePicker(),
327+
"delivery_instructions": MarkdownWidget(),
323328
}
324329

325330

netbox_inventory/forms/reassign.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ class AssetReassignMixin(forms.Form):
3333
required=False,
3434
help_text='Limit New Asset choices only to assets stored at this site',
3535
)
36+
storage_site = DynamicModelChoiceField(
37+
queryset=Site.objects.all(),
38+
required=False,
39+
initial_params={
40+
"locations": "$storage_location",
41+
},
42+
help_text='Limit New Asset choices only to assets stored at this site',
43+
)
3644
storage_location = DynamicModelChoiceField(
3745
queryset=Location.objects.all(),
3846
required=False,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.1.7 on 2025-05-16 03:59
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('dcim', '0200_populate_mac_addresses'),
11+
('netbox_inventory', '0019_asset_eol_date'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='asset',
17+
name='storage_site',
18+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.site'),
19+
),
20+
migrations.AddField(
21+
model_name='delivery',
22+
name='delivery_site',
23+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.site'),
24+
),
25+
migrations.AddField(
26+
model_name='purchase',
27+
name='delivery_instructions',
28+
field=models.TextField(blank=True),
29+
),
30+
]

netbox_inventory/models/assets.py

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,17 @@ class Asset(NetBoxModel, ImageAttachmentsMixin):
243243
blank=True,
244244
null=True,
245245
)
246-
246+
storage_site = models.ForeignKey(
247+
help_text='Site where this asset is stored when not in use',
248+
to='dcim.Site',
249+
on_delete=models.PROTECT,
250+
related_name='+',
251+
blank=True,
252+
null=True,
253+
verbose_name='Storage Site',
254+
)
247255
storage_location = models.ForeignKey(
248-
help_text='Where is this asset stored when not in use',
256+
help_text='Location where this asset is stored when not in use',
249257
to='dcim.Location',
250258
on_delete=models.PROTECT,
251259
related_name='+',
@@ -336,6 +344,7 @@ class Asset(NetBoxModel, ImageAttachmentsMixin):
336344
'eol_date',
337345
'tenant',
338346
'contact',
347+
'storage_site',
339348
'storage_location',
340349
'comments',
341350
]
@@ -369,11 +378,6 @@ def hardware_type(self):
369378
def hardware(self):
370379
return self.device or self.module or self.inventoryitem or self.rack or None
371380

372-
@property
373-
def storage_site(self):
374-
if self.storage_location:
375-
return self.storage_location.site
376-
377381
@property
378382
def installed_site(self):
379383
device = self.installed_device
@@ -499,13 +503,35 @@ def clean(self):
499503
self.validate_hardware()
500504
self.update_status()
501505
self.update_location()
506+
self.infer_storage_site()
502507
self.sync_hardware_eol()
503508
return super().clean()
504509

505510
def save(self, clear_old_hw=True, *args, **kwargs):
506511
self.update_hardware_used(clear_old_hw)
507512
return super().save(*args, **kwargs)
508513

514+
def clean_delivery(self):
515+
if self.delivery and not self.purchase:
516+
self.purchase = self.delivery.purchases.first()
517+
if self.delivery:
518+
if self.purchase and self.purchase not in self.delivery.purchases.all():
519+
raise ValidationError(
520+
{
521+
'purchase': 'The selected purchase is not associated with the delivery.'
522+
}
523+
)
524+
525+
def clean_warranty_dates(self):
526+
if (
527+
self.warranty_start
528+
and self.warranty_end
529+
and self.warranty_end <= self.warranty_start
530+
):
531+
raise ValidationError(
532+
{'warranty_end': 'Warranty end date must be after warranty start date.'}
533+
)
534+
509535
def validate_hardware_types(self):
510536
"""
511537
Ensure only one device/module_type/inventoryitem_type/rack_type is set at a time.
@@ -581,7 +607,8 @@ def update_status(self):
581607
ordered_status = get_status_for('ordered')
582608
planned_status = get_status_for('planned')
583609

584-
# Manual/Bulk Assignment: Status has been set manually or Asset is part of bulk assignment; do not change it
610+
# Manual/Bulk Assignment: Status has been set manually or Asset is part of bulk assignment;
611+
# do not change it
585612
if not getattr(self, '_in_bulk_assignment', False) and old_status != self.status:
586613
return
587614

@@ -603,6 +630,28 @@ def update_status(self):
603630
# Planned: Default status
604631
self.status = planned_status
605632

633+
def update_location(self):
634+
"""
635+
Update the location of the Asset based on the location of the assigned Delivery. Only
636+
update if the assigned Delivery changes.
637+
"""
638+
old_delivery = get_prechange_field(self, 'delivery')
639+
new_delivery = self.delivery
640+
new_hw = getattr(self, self.kind)
641+
642+
if old_delivery != new_delivery:
643+
if new_delivery and not new_hw:
644+
self.storage_site = new_delivery.delivery_site
645+
self.storage_location = new_delivery.delivery_location
646+
647+
def infer_storage_site(self):
648+
"""
649+
If only storage_location is set, infer storage_site from it
650+
"""
651+
if self.storage_location and not self.storage_site:
652+
self.storage_site = self.storage_location.site
653+
return
654+
606655
def update_hardware_used(self, clear_old_hw=True):
607656
"""
608657
If assigning as device, module, inventoryitem or rack set serial and
@@ -632,29 +681,6 @@ def update_hardware_used(self, clear_old_hw=True):
632681
if new_hw:
633682
asset_set_new_hw(asset=self, hw=new_hw)
634683

635-
def update_location(self):
636-
"""
637-
Update the location of the asset based on the location of the assigned
638-
delivery.
639-
"""
640-
new_hw = getattr(self, self.kind)
641-
642-
if self.delivery and not new_hw:
643-
self.storage_location = self.delivery.delivery_location
644-
else:
645-
self.storage_location = None
646-
647-
def clean_delivery(self):
648-
if self.delivery and not self.purchase:
649-
self.purchase = self.delivery.purchases.first()
650-
if self.delivery:
651-
if self.purchase and self.purchase not in self.delivery.purchases.all():
652-
raise ValidationError(
653-
{
654-
'purchase': 'The selected purchase is not associated with the delivery.'
655-
}
656-
)
657-
658684
def sync_hardware_eol(self):
659685
"""
660686
Sync asset's eol_date from corresponding hardware type if plugin setting is enabled.
@@ -679,16 +705,6 @@ def sync_hardware_eol(self):
679705
eol_date = hw_type.cf.get('eol_date') if hasattr(hw_type, 'cf') else None
680706
self.eol_date = eol_date if eol_date else None
681707

682-
def clean_warranty_dates(self):
683-
if (
684-
self.warranty_start
685-
and self.warranty_end
686-
and self.warranty_end <= self.warranty_start
687-
):
688-
raise ValidationError(
689-
{'warranty_end': 'Warranty end date must be after warranty start date.'}
690-
)
691-
692708
def get_absolute_url(self):
693709
return reverse('plugins:netbox_inventory:asset', args=[self.pk])
694710

0 commit comments

Comments
 (0)