Merge branch 'develop' into 17419-mptt-module-bay

This commit is contained in:
Arthur Hanson 2024-09-24 13:16:08 -07:00
commit 0dd2a65007
21 changed files with 111 additions and 48 deletions

View File

@ -1,5 +1,25 @@
# NetBox v4.1 # NetBox v4.1
## v4.1.2 (FUTURE)
### Enhancements
* [#14201](https://github.com/netbox-community/netbox/issues/14201) - Enable global search for AS numbers using "AS" prefix
* [#15408](https://github.com/netbox-community/netbox/issues/15408) - Enable bulk import of primary IPv4 & IPv6 addresses for virtual device contexts (VDCs)
* [#17471](https://github.com/netbox-community/netbox/issues/17471) - Add Eaton C39 power outlet type
* [#17482](https://github.com/netbox-community/netbox/issues/17482) - Do not preload Branch & StagedChange models in `nbshell`
### Bug Fixes
* [#17083](https://github.com/netbox-community/netbox/issues/17083) - Trim clickable area of form field labels
* [#17406](https://github.com/netbox-community/netbox/issues/17406) - Fix the cleanup of stale custom field data after removing a plugin
* [#17492](https://github.com/netbox-community/netbox/issues/17492) - Fix URL resolution in `NetBoxModelSerializer` for plugin models
* [#17517](https://github.com/netbox-community/netbox/issues/17517) - Fix cable termination selection after switching termination type
* [#17521](https://github.com/netbox-community/netbox/issues/17521) - Correct text color in notification pop-ups under dark mode
* [#17522](https://github.com/netbox-community/netbox/issues/17522) - Fix language translation of form field labels under user preferences
---
## v4.1.1 (2024-09-12) ## v4.1.1 (2024-09-12)
### Enhancements ### Enhancements

View File

@ -924,6 +924,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AY = 'ieee802.11ay' TYPE_80211AY = 'ieee802.11ay'
TYPE_80211BE = 'ieee802.11be' TYPE_80211BE = 'ieee802.11be'
TYPE_802151 = 'ieee802.15.1' TYPE_802151 = 'ieee802.15.1'
TYPE_802154 = 'ieee802.15.4'
TYPE_OTHER_WIRELESS = 'other-wireless' TYPE_OTHER_WIRELESS = 'other-wireless'
# Cellular # Cellular
@ -1096,6 +1097,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AY, 'IEEE 802.11ay'), (TYPE_80211AY, 'IEEE 802.11ay'),
(TYPE_80211BE, 'IEEE 802.11be'), (TYPE_80211BE, 'IEEE 802.11be'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
(TYPE_802154, 'IEEE 802.15.4 (LR-WPAN)'),
(TYPE_OTHER_WIRELESS, 'Other (Wireless)'), (TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
) )
), ),

View File

@ -51,6 +51,7 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211AY, InterfaceTypeChoices.TYPE_80211AY,
InterfaceTypeChoices.TYPE_80211BE, InterfaceTypeChoices.TYPE_80211BE,
InterfaceTypeChoices.TYPE_802151, InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_802154,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS, InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
] ]

View File

@ -9,7 +9,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import VRF from ipam.models import VRF, IPAddress
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import ( from utilities.forms.fields import (
@ -1435,9 +1435,33 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Status'), label=_('Status'),
choices=VirtualDeviceContextStatusChoices, choices=VirtualDeviceContextStatusChoices,
) )
primary_ip4 = CSVModelChoiceField(
label=_('Primary IPv4'),
queryset=IPAddress.objects.all(),
required=False,
to_field_name='address',
help_text=_('IPv4 address with mask, e.g. 1.2.3.4/24')
)
primary_ip6 = CSVModelChoiceField(
label=_('Primary IPv6'),
queryset=IPAddress.objects.all(),
required=False,
to_field_name='address',
help_text=_('IPv6 address with prefix length, e.g. 2001:db8::1/64')
)
class Meta: class Meta:
fields = [ fields = [
'name', 'device', 'status', 'tenant', 'identifier', 'comments', 'name', 'device', 'status', 'tenant', 'identifier', 'comments', 'primary_ip4', 'primary_ip6',
] ]
model = VirtualDeviceContext model = VirtualDeviceContext
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit primary_ip4/ip6 querysets by assigned device
params = {f"interface__device__{self.fields['device'].to_field_name}": data.get('device')}
self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params)
self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params)

View File

@ -164,7 +164,7 @@ class Cable(PrimaryModel):
if self.length is not None and not self.length_unit: if self.length is not None and not self.length_unit:
raise ValidationError(_("Must specify a unit when setting a cable length")) raise ValidationError(_("Must specify a unit when setting a cable length"))
if self.pk is None and (not self.a_terminations or not self.b_terminations): if self._state.adding and (not self.a_terminations or not self.b_terminations):
raise ValidationError(_("Must define A and B terminations when creating a new cable.")) raise ValidationError(_("Must define A and B terminations when creating a new cable."))
if self._terminations_modified: if self._terminations_modified:

View File

@ -98,7 +98,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
def clean(self): def clean(self):
super().clean() super().clean()
if self.pk is not None and self._original_device_type != self.device_type_id: if not self._state.adding and self._original_device_type != self.device_type_id:
raise ValidationError({ raise ValidationError({
"device_type": _("Component templates cannot be moved to a different device type.") "device_type": _("Component templates cannot be moved to a different device type.")
}) })

View File

@ -561,7 +561,7 @@ class BaseInterface(models.Model):
self.untagged_vlan = None self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear() self.tagged_vlans.clear()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@ -1072,7 +1072,7 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
super().clean() super().clean()
# Check that positions count is greater than or equal to the number of associated FrontPorts # Check that positions count is greater than or equal to the number of associated FrontPorts
if self.pk: if not self._state.adding:
frontport_count = self.frontports.count() frontport_count = self.frontports.count()
if self.positions < frontport_count: if self.positions < frontport_count:
raise ValidationError({ raise ValidationError({
@ -1314,7 +1314,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
}) })
# Validation for moving InventoryItems # Validation for moving InventoryItems
if self.pk: if not self._state.adding:
# Cannot move an InventoryItem to another device if it has a parent # Cannot move an InventoryItem to another device if it has a parent
if self.parent and self.parent.device != self.device: if self.parent and self.parent.device != self.device:
raise ValidationError({ raise ValidationError({

View File

@ -293,7 +293,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
# room to expand within their racks. This validation will impose a very high performance penalty when there are # room to expand within their racks. This validation will impose a very high performance penalty when there are
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
if self.pk and self.u_height > self._original_u_height: if not self._state.adding and self.u_height > self._original_u_height:
for d in Device.objects.filter(device_type=self, position__isnull=False): for d in Device.objects.filter(device_type=self, position__isnull=False):
face_required = None if self.is_full_depth else d.face face_required = None if self.is_full_depth else d.face
u_available = d.rack.get_available_units( u_available = d.rack.get_available_units(
@ -310,7 +310,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
}) })
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
elif self.pk and self._original_u_height > 0 and self.u_height == 0: elif not self._state.adding and self._original_u_height > 0 and self.u_height == 0:
racked_instance_count = Device.objects.filter( racked_instance_count = Device.objects.filter(
device_type=self, device_type=self,
position__isnull=False position__isnull=False
@ -1351,7 +1351,7 @@ class VirtualChassis(PrimaryModel):
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
# VirtualChassis.) # VirtualChassis.)
if self.pk and self.master and self.master not in self.members.all(): if not self._state.adding and self.master and self.master not in self.members.all():
raise ValidationError({ raise ValidationError({
'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format( 'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format(
master=self.master master=self.master

View File

@ -382,7 +382,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
if self.max_weight and not self.weight_unit: if self.max_weight and not self.weight_unit:
raise ValidationError(_("Must specify a unit when setting a maximum weight")) raise ValidationError(_("Must specify a unit when setting a maximum weight"))
if self.pk: if not self._state.adding:
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
# Validate that Rack is tall enough to house the highest mounted Device # Validate that Rack is tall enough to house the highest mounted Device
@ -468,7 +468,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
} }
# Add devices to rack units list # Add devices to rack units list
if self.pk: if not self._state.adding:
# Retrieve all devices installed within the rack # Retrieve all devices installed within the rack
devices = Device.objects.prefetch_related( devices = Device.objects.prefetch_related(

View File

@ -684,7 +684,8 @@ class DeviceInterfaceTable(InterfaceTable):
'data-virtual': lambda record: "true" if record.is_virtual else "false", 'data-virtual': lambda record: "true" if record.is_virtual else "false",
'data-mark-connected': lambda record: "true" if record.mark_connected else "false", 'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
'data-cable-status': lambda record: record.cable.status if record.cable else "", 'data-cable-status': lambda record: record.cable.status if record.cable else "",
'data-type': lambda record: record.type 'data-type': lambda record: record.type,
'data-connected': lambda record: "connected" if record.mark_connected or record.cable else "disconnected"
} }

View File

@ -5247,6 +5247,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_type(self): def test_type(self):
params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'type__empty': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'type__empty': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_status(self): def test_status(self):
params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} params = {'status': [LinkStatusChoices.STATUS_CONNECTED]}

View File

@ -3253,10 +3253,10 @@ class CableEditView(generic.ObjectEditView):
doesn't currently provide a hook for dynamic class resolution. doesn't currently provide a hook for dynamic class resolution.
""" """
a_terminations_type = CABLE_TERMINATION_TYPES.get( a_terminations_type = CABLE_TERMINATION_TYPES.get(
request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type') request.POST.get('a_terminations_type') or request.GET.get('a_terminations_type')
) )
b_terminations_type = CABLE_TERMINATION_TYPES.get( b_terminations_type = CABLE_TERMINATION_TYPES.get(
request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type') request.POST.get('b_terminations_type') or request.GET.get('b_terminations_type')
) )
if obj.pk: if obj.pk:

View File

@ -29,6 +29,7 @@ class ASNIndex(SearchIndex):
class ASNRangeIndex(SearchIndex): class ASNRangeIndex(SearchIndex):
model = models.ASNRange model = models.ASNRange
fields = ( fields = (
('name', 100),
('description', 500), ('description', 500),
) )
display_attrs = ('rir', 'tenant', 'description') display_attrs = ('rir', 'tenant', 'description')

View File

@ -133,7 +133,7 @@ class BaseFilterSet(django_filters.FilterSet):
django_filters.ModelChoiceFilter, django_filters.ModelChoiceFilter,
django_filters.ModelMultipleChoiceFilter, django_filters.ModelMultipleChoiceFilter,
TagFilter TagFilter
)) or existing_filter.extra.get('choices'): )):
# These filter types support only negation # These filter types support only negation
return FILTER_NEGATION_LOOKUP_MAP return FILTER_NEGATION_LOOKUP_MAP
@ -172,6 +172,7 @@ class BaseFilterSet(django_filters.FilterSet):
# Create new filters for each lookup expression in the map # Create new filters for each lookup expression in the map
for lookup_name, lookup_expr in lookup_map.items(): for lookup_name, lookup_expr in lookup_map.items():
new_filter_name = f'{existing_filter_name}__{lookup_name}' new_filter_name = f'{existing_filter_name}__{lookup_name}'
existing_filter_extra = deepcopy(existing_filter.extra)
try: try:
if existing_filter_name in cls.declared_filters: if existing_filter_name in cls.declared_filters:
@ -179,6 +180,8 @@ class BaseFilterSet(django_filters.FilterSet):
# create the new filter with the same type because there is no guarantee the defined type # create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field # is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
for field_to_remove in ('choices', 'null_value'):
existing_filter_extra.pop(field_to_remove, None)
filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
new_filter = filter_cls( new_filter = filter_cls(
field_name=field_name, field_name=field_name,
@ -186,7 +189,7 @@ class BaseFilterSet(django_filters.FilterSet):
label=existing_filter.label, label=existing_filter.label,
exclude=existing_filter.exclude, exclude=existing_filter.exclude,
distinct=existing_filter.distinct, distinct=existing_filter.distinct,
**existing_filter.extra **existing_filter_extra
) )
elif hasattr(existing_filter, 'custom_field'): elif hasattr(existing_filter, 'custom_field'):
# Filter is for a custom field # Filter is for a custom field

View File

@ -162,7 +162,7 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
super().clean() super().clean()
# An MPTT model cannot be its own parent # An MPTT model cannot be its own parent
if self.pk and self.parent and self.parent in self.get_descendants(include_self=True): if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True):
raise ValidationError({ raise ValidationError({
"parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name) "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name)
}) })

View File

@ -408,14 +408,9 @@ class JobsMixin(models.Model):
def get_latest_jobs(self): def get_latest_jobs(self):
""" """
Return a dictionary mapping of the most recent jobs for this instance. Return a list of the most recent jobs for this instance.
""" """
return { return self.jobs.filter(status__in=JobStatusChoices.TERMINAL_STATE_CHOICES).order_by('-created').defer('data')
job.name: job
for job in self.jobs.filter(
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}
class JournalingMixin(models.Model): class JournalingMixin(models.Model):

View File

@ -50,7 +50,7 @@
</thead> </thead>
<tbody> <tbody>
{% for script in module.scripts.all %} {% for script in module.scripts.all %}
{% with last_job=script.get_latest_jobs|get_key:script.name %} {% with last_job=script.get_latest_jobs|first %}
<tr> <tr>
<td> <td>
{% if script.is_executable %} {% if script.is_executable %}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-18 05:02+0000\n" "POT-Creation-Date: 2024-09-20 05:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -4492,6 +4492,28 @@ msgstr ""
msgid "Single or three-phase" msgid "Single or three-phase"
msgstr "" msgstr ""
#: netbox/dcim/forms/bulk_import.py:1439 netbox/dcim/forms/model_forms.py:1669
#: netbox/templates/dcim/device.html:190
#: netbox/templates/dcim/virtualdevicecontext.html:30
#: netbox/templates/virtualization/virtualmachine.html:52
msgid "Primary IPv4"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1443
msgid "IPv4 address with mask, e.g. 1.2.3.4/24"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1446 netbox/dcim/forms/model_forms.py:1678
#: netbox/templates/dcim/device.html:206
#: netbox/templates/dcim/virtualdevicecontext.html:41
#: netbox/templates/virtualization/virtualmachine.html:68
msgid "Primary IPv6"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1450
msgid "IPv6 address with prefix length, e.g. 2001:db8::1/64"
msgstr ""
#: netbox/dcim/forms/common.py:24 netbox/dcim/models/device_components.py:528 #: netbox/dcim/forms/common.py:24 netbox/dcim/models/device_components.py:528
#: netbox/templates/dcim/interface.html:57 #: netbox/templates/dcim/interface.html:57
#: netbox/templates/virtualization/vminterface.html:55 #: netbox/templates/virtualization/vminterface.html:55
@ -4898,18 +4920,6 @@ msgstr ""
msgid "Inventory Item Role" msgid "Inventory Item Role"
msgstr "" msgstr ""
#: netbox/dcim/forms/model_forms.py:1669 netbox/templates/dcim/device.html:190
#: netbox/templates/dcim/virtualdevicecontext.html:30
#: netbox/templates/virtualization/virtualmachine.html:52
msgid "Primary IPv4"
msgstr ""
#: netbox/dcim/forms/model_forms.py:1678 netbox/templates/dcim/device.html:206
#: netbox/templates/dcim/virtualdevicecontext.html:41
#: netbox/templates/virtualization/virtualmachine.html:68
msgid "Primary IPv6"
msgstr ""
#: netbox/dcim/forms/object_create.py:48 netbox/dcim/forms/object_create.py:199 #: netbox/dcim/forms/object_create.py:48 netbox/dcim/forms/object_create.py:199
#: netbox/dcim/forms/object_create.py:355 #: netbox/dcim/forms/object_create.py:355
msgid "" msgid ""
@ -12187,7 +12197,7 @@ msgid "Parent Bay"
msgstr "" msgstr ""
#: netbox/templates/dcim/device_edit.html:48 #: netbox/templates/dcim/device_edit.html:48
#: netbox/utilities/templates/form_helpers/render_field.html:20 #: netbox/utilities/templates/form_helpers/render_field.html:22
msgid "Regenerate Slug" msgid "Regenerate Slug"
msgstr "" msgstr ""
@ -14695,15 +14705,15 @@ msgstr ""
msgid "Subscribe" msgid "Subscribe"
msgstr "" msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:39 #: netbox/utilities/templates/form_helpers/render_field.html:41
msgid "Copy to clipboard" msgid "Copy to clipboard"
msgstr "" msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:55 #: netbox/utilities/templates/form_helpers/render_field.html:57
msgid "This field is required" msgid "This field is required"
msgstr "" msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:68 #: netbox/utilities/templates/form_helpers/render_field.html:70
msgid "Set Null" msgid "Set Null"
msgstr "" msgstr ""

View File

@ -6,9 +6,11 @@
{# Render the field label (if any), except for checkboxes #} {# Render the field label (if any), except for checkboxes #}
{% if label and not field|widget_type == 'checkboxinput' %} {% if label and not field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}"> <div class="col-sm-3 text-lg-end">
<label for="{{ field.id_for_label }}" class="col-form-label d-inline-block{% if field.field.required %} required{% endif %}">
{{ label }} {{ label }}
</label> </label>
</div>
{% endif %} {% endif %}
{# Render the field itself #} {# Render the field itself #}

View File

@ -134,7 +134,7 @@ class Cluster(ContactsMixin, PrimaryModel):
super().clean() super().clean()
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
if self.pk and self.site: if not self._state.adding and self.site:
if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count(): if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
raise ValidationError({ raise ValidationError({
'site': _( 'site': _(

View File

@ -205,7 +205,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
}) })
# Validate aggregate disk size # Validate aggregate disk size
if self.pk: if not self._state.adding:
total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum'] total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
if total_disk and self.disk is None: if total_disk and self.disk is None:
self.disk = total_disk self.disk = total_disk