Coverage for virtualization/forms/model_forms.py: 56%
154 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django import forms
2from django.apps import apps
3from django.conf import settings
4from django.contrib.contenttypes.models import ContentType
5from django.core.exceptions import ValidationError
6from django.utils.translation import gettext_lazy as _
8from dcim.choices import InterfaceModeChoices
9from dcim.forms.common import InterfaceCommonForm
10from dcim.forms.mixins import ScopedForm
11from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup
12from extras.models import ConfigTemplate
13from ipam.choices import VLANQinQRoleChoices
14from ipam.models import VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
15from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
16from netbox.forms.mixins import OwnerMixin
17from tenancy.forms import TenancyForm
18from utilities.forms import ConfirmationForm, add_blank_choice, get_field_value
19from utilities.forms.fields import (
20 ChoiceField,
21 DynamicModelChoiceField,
22 DynamicModelMultipleChoiceField,
23 JSONField,
24 SlugField,
25 TypedChoiceField,
26)
27from utilities.forms.rendering import FieldSet
28from utilities.forms.utils import get_capacity_unit_label
29from utilities.forms.widgets import HTMXSelect
30from virtualization.choices import (
31 ClusterStatusChoices,
32 VirtualMachineStartOnBootChoices,
33 VirtualMachineStatusChoices,
34)
36from ..models import *
38__all__ = (
39 'ClusterAddDevicesForm',
40 'ClusterForm',
41 'ClusterGroupForm',
42 'ClusterRemoveDevicesForm',
43 'ClusterTypeForm',
44 'VMInterfaceForm',
45 'VirtualDiskForm',
46 'VirtualMachineForm',
47 'VirtualMachineTypeForm',
48)
51class ClusterTypeForm(OrganizationalModelForm):
52 fieldsets = (
53 FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Type')),
54 )
56 class Meta:
57 model = ClusterType
58 fields = (
59 'name', 'slug', 'description', 'owner', 'comments', 'tags',
60 )
63class ClusterGroupForm(OrganizationalModelForm):
64 fieldsets = (
65 FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Group')),
66 )
68 class Meta:
69 model = ClusterGroup
70 fields = (
71 'name', 'slug', 'description', 'owner', 'comments', 'tags',
72 )
75class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm):
76 status = ChoiceField(
77 label=_('Status'),
78 choices=ClusterStatusChoices,
79 initial=ClusterStatusChoices.STATUS_ACTIVE,
80 )
81 type = DynamicModelChoiceField(
82 label=_('Type'),
83 queryset=ClusterType.objects.all(),
84 quick_add=True
85 )
86 group = DynamicModelChoiceField(
87 label=_('Group'),
88 queryset=ClusterGroup.objects.all(),
89 required=False,
90 quick_add=True
91 )
93 fieldsets = (
94 FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')),
95 FieldSet('scope', name=_('Scope'), html_id='scope'),
96 FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
97 )
99 class Meta:
100 model = Cluster
101 fields = (
102 'name', 'type', 'group', 'status', 'tenant', 'description', 'owner', 'comments', 'tags',
103 )
106class ClusterAddDevicesForm(forms.Form):
107 region = DynamicModelChoiceField(
108 label=_('Region'),
109 queryset=Region.objects.all(),
110 required=False,
111 null_option='None'
112 )
113 site_group = DynamicModelChoiceField(
114 label=_('Site group'),
115 queryset=SiteGroup.objects.all(),
116 required=False,
117 null_option='None'
118 )
119 site = DynamicModelChoiceField(
120 label=_('Site'),
121 queryset=Site.objects.all(),
122 required=False,
123 query_params={
124 'region_id': '$region',
125 'group_id': '$site_group',
126 }
127 )
128 rack = DynamicModelChoiceField(
129 label=_('Rack'),
130 queryset=Rack.objects.all(),
131 required=False,
132 null_option='None',
133 query_params={
134 'site_id': '$site'
135 }
136 )
137 devices = DynamicModelMultipleChoiceField(
138 label=_('Devices'),
139 queryset=Device.objects.all(),
140 query_params={
141 'site_id': '$site',
142 'rack_id': '$rack',
143 'cluster_id': 'null',
144 }
145 )
147 class Meta:
148 fields = [
149 'region', 'site', 'rack', 'devices',
150 ]
152 def __init__(self, cluster, *args, **kwargs):
154 self.cluster = cluster
156 super().__init__(*args, **kwargs)
158 self.fields['devices'].choices = []
160 def clean(self):
161 super().clean()
163 # If the Cluster is assigned to a Site or Location, all Devices must be assigned to that same scope.
164 if self.cluster.scope is not None:
165 for device in self.cleaned_data.get('devices', []):
166 for scope_field in ['site', 'location']:
167 device_scope = getattr(device, scope_field)
168 if (
169 self.cluster.scope_type.model_class() == apps.get_model('dcim', scope_field) and
170 device_scope != self.cluster.scope
171 ):
172 raise ValidationError({
173 'devices': _(
174 "{device} belongs to a different {scope_field} ({device_scope}) than the "
175 "cluster ({cluster_scope})"
176 ).format(
177 device=device,
178 scope_field=scope_field,
179 device_scope=device_scope,
180 cluster_scope=self.cluster.scope
181 )
182 })
185class ClusterRemoveDevicesForm(ConfirmationForm):
186 pk = forms.ModelMultipleChoiceField(
187 queryset=Device.objects.all(),
188 widget=forms.MultipleHiddenInput()
189 )
192class VirtualMachineTypeForm(PrimaryModelForm):
193 slug = SlugField()
194 default_platform = DynamicModelChoiceField(
195 label=_('Default platform'),
196 queryset=Platform.objects.all(),
197 required=False,
198 selector=True,
199 )
201 fieldsets = (
202 FieldSet('name', 'slug', 'description', 'tags', name=_('Virtual Machine Type')),
203 FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')),
204 )
206 class Meta:
207 model = VirtualMachineType
208 fields = (
209 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description',
210 'owner', 'comments', 'tags',
211 )
213 def __init__(self, *args, **kwargs):
214 super().__init__(*args, **kwargs)
216 # Set unit label based on configured RAM_BASE_UNIT (MB vs MiB)
217 self.fields['default_memory'].label = _('Default memory ({unit})').format(
218 unit=get_capacity_unit_label(settings.RAM_BASE_UNIT)
219 )
222class VirtualMachineForm(TenancyForm, PrimaryModelForm):
223 status = ChoiceField(
224 label=_('Status'),
225 choices=VirtualMachineStatusChoices,
226 initial=VirtualMachineStatusChoices.STATUS_ACTIVE,
227 )
228 start_on_boot = ChoiceField(
229 label=_('Start on boot'),
230 choices=VirtualMachineStartOnBootChoices,
231 initial=VirtualMachineStartOnBootChoices.STATUS_OFF,
232 )
233 virtual_machine_type = forms.ModelChoiceField(
234 label=_('Type'),
235 queryset=VirtualMachineType.objects.all(),
236 required=False,
237 # No hx_target_id: type change populates defaults across both the Virtual Machine
238 # and Resources fieldsets, so a single-fieldset partial swap would miss half the update.
239 widget=HTMXSelect(),
240 )
241 site = DynamicModelChoiceField(
242 label=_('Site'),
243 queryset=Site.objects.all(),
244 required=False,
245 help_text=_(
246 'The site where this VM resides. Will be inferred automatically from the '
247 'assigned cluster or device if left blank.'
248 ),
249 )
250 cluster = DynamicModelChoiceField(
251 label=_('Cluster'),
252 queryset=Cluster.objects.all(),
253 required=False,
254 selector=True,
255 query_params={
256 'site_id': ['$site', 'null']
257 },
258 help_text=_('Assign this VM to a cluster. Required when selecting a device that belongs to a cluster.'),
259 )
260 device = DynamicModelChoiceField(
261 label=_('Device'),
262 queryset=Device.objects.all(),
263 required=False,
264 selector=True,
265 query_params={
266 'cluster_id': '$cluster',
267 'site_id': '$site',
268 },
269 help_text=_(
270 'Optionally pin this VM to a specific host device within a cluster, '
271 'or assign it directly to a standalone device.'
272 )
273 )
274 role = DynamicModelChoiceField(
275 label=_('Role'),
276 queryset=DeviceRole.objects.all(),
277 required=False,
278 query_params={
279 "vm_role": "True"
280 }
281 )
282 platform = DynamicModelChoiceField(
283 label=_('Platform'),
284 queryset=Platform.objects.all(),
285 required=False,
286 selector=True
287 )
288 local_context_data = JSONField(
289 required=False,
290 label='',
291 widget=forms.Textarea(attrs={'aria-label': _('Local config context data')})
292 )
293 config_template = DynamicModelChoiceField(
294 queryset=ConfigTemplate.objects.all(),
295 required=False,
296 label=_('Config template')
297 )
299 fieldsets = (
300 FieldSet(
301 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags',
302 name=_('Virtual Machine')
303 ),
304 FieldSet('site', 'cluster', 'device', name=_('Placement')),
305 FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
306 FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')),
307 FieldSet('vcpus', 'memory', 'disk', name=_('Resources')),
308 FieldSet('local_context_data', name=_('Config Context')),
309 )
311 class Meta:
312 model = VirtualMachine
313 fields = [
314 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
315 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial',
316 'tenant_group', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', 'config_template',
317 ]
319 def __init__(self, *args, **kwargs):
320 super().__init__(*args, **kwargs)
322 # Set unit labels based on configured RAM_BASE_UNIT / DISK_BASE_UNIT (MB vs MiB)
323 self.fields['memory'].label = _('Memory ({unit})').format(unit=get_capacity_unit_label(settings.RAM_BASE_UNIT))
324 self.fields['disk'].label = _('Disk ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT))
326 # Populate virtual machine type defaults, if any
327 self._populate_virtual_machine_type_defaults()
329 if self.instance.pk:
331 # Disable the disk field if one or more VirtualDisks have been created
332 if self.instance.virtualdisks.exists():
333 self.fields['disk'].widget.attrs['disabled'] = True
334 self.fields['disk'].help_text = _("Disk size is managed via the attachment of virtual disks.")
336 # Compile list of choices for primary IPv4 and IPv6 addresses
337 for family in [4, 6]:
338 ip_choices = [(None, '---------')]
340 # Gather PKs of all interfaces belonging to this VM
341 interface_ids = self.instance.interfaces.values_list('pk', flat=True)
343 # Collect interface IPs
344 interface_ips = IPAddress.objects.filter(
345 address__family=family,
346 assigned_object_type=ContentType.objects.get_for_model(VMInterface),
347 assigned_object_id__in=interface_ids
348 )
349 if interface_ips:
350 ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
351 ip_choices.append(('Interface IPs', ip_list))
352 # Collect NAT IPs
353 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
354 address__family=family,
355 nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
356 nat_inside__assigned_object_id__in=interface_ids
357 )
358 if nat_ips:
359 ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
360 ip_choices.append(('NAT IPs', ip_list))
361 self.fields['primary_ip{}'.format(family)].choices = ip_choices
363 else:
365 # An object that doesn't exist yet can't have any IPs assigned to it
366 self.fields.pop('primary_ip4')
367 self.fields.pop('primary_ip6')
369 def _populate_virtual_machine_type_defaults(self):
370 """
371 Populate platform/vCPUs/memory from the selected VirtualMachineType.
373 For new VMs, always apply defaults. For existing VMs, only apply
374 defaults when the type changes and the field is currently empty.
375 """
376 if not (virtual_machine_type_id := get_field_value(self, 'virtual_machine_type')):
377 return
379 is_new = not self.instance.pk
381 # Skip if editing and the type hasn't changed
382 if not is_new and int(virtual_machine_type_id) == self.instance.virtual_machine_type_id:
383 return
385 if not (virtual_machine_type := VirtualMachineType.objects.filter(pk=virtual_machine_type_id).first()):
386 return
388 defaults = {
389 'platform': ('default_platform_id', 'platform_id'),
390 'vcpus': ('default_vcpus', 'vcpus'),
391 'memory': ('default_memory', 'memory'),
392 }
394 for field_name, (type_attr, instance_attr) in defaults.items():
395 default_value = getattr(virtual_machine_type, type_attr)
396 if default_value is not None and (is_new or getattr(self.instance, instance_attr) is None):
397 self.initial[field_name] = default_value
400#
401# Virtual machine components
402#
404class VMComponentForm(OwnerMixin, NetBoxModelForm):
405 virtual_machine = DynamicModelChoiceField(
406 label=_('Virtual machine'),
407 queryset=VirtualMachine.objects.all(),
408 selector=True
409 )
411 def __init__(self, *args, **kwargs):
412 super().__init__(*args, **kwargs)
414 # Disable reassignment of VirtualMachine when editing an existing instance
415 if self.instance.pk:
416 self.fields['virtual_machine'].disabled = True
419class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
420 mode = TypedChoiceField(
421 label=_('802.1Q Mode'),
422 choices=add_blank_choice(InterfaceModeChoices),
423 required=False,
424 help_text=_('IEEE 802.1Q tagging strategy'),
425 widget=HTMXSelect(hx_target_id='dot1q-switching'),
426 )
427 primary_mac_address = DynamicModelChoiceField(
428 queryset=MACAddress.objects.all(),
429 label=_('Primary MAC address'),
430 required=False,
431 quick_add=True,
432 quick_add_params={'vminterface': '$pk'}
433 )
434 parent = DynamicModelChoiceField(
435 queryset=VMInterface.objects.all(),
436 required=False,
437 label=_('Parent interface'),
438 query_params={
439 'virtual_machine_id': '$virtual_machine',
440 }
441 )
442 bridge = DynamicModelChoiceField(
443 queryset=VMInterface.objects.all(),
444 required=False,
445 label=_('Bridged interface'),
446 query_params={
447 'virtual_machine_id': '$virtual_machine',
448 }
449 )
450 vlan_group = DynamicModelChoiceField(
451 queryset=VLANGroup.objects.all(),
452 required=False,
453 label=_('VLAN group')
454 )
455 untagged_vlan = DynamicModelChoiceField(
456 queryset=VLAN.objects.all(),
457 required=False,
458 label=_('Untagged VLAN'),
459 query_params={
460 'group_id': '$vlan_group',
461 'available_on_virtualmachine': '$virtual_machine',
462 }
463 )
464 tagged_vlans = DynamicModelMultipleChoiceField(
465 queryset=VLAN.objects.all(),
466 required=False,
467 label=_('Tagged VLANs'),
468 query_params={
469 'group_id': '$vlan_group',
470 'available_on_virtualmachine': '$virtual_machine',
471 }
472 )
473 qinq_svlan = DynamicModelChoiceField(
474 queryset=VLAN.objects.all(),
475 required=False,
476 label=_('Q-in-Q Service VLAN'),
477 query_params={
478 'group_id': '$vlan_group',
479 'available_on_virtualmachine': '$virtual_machine',
480 'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
481 }
482 )
483 vrf = DynamicModelChoiceField(
484 queryset=VRF.objects.all(),
485 required=False,
486 label=_('VRF')
487 )
488 vlan_translation_policy = DynamicModelChoiceField(
489 queryset=VLANTranslationPolicy.objects.all(),
490 required=False,
491 label=_('VLAN Translation Policy')
492 )
494 fieldsets = (
495 FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
496 FieldSet('vrf', 'mac_address', name=_('Addressing')),
497 FieldSet('mtu', 'enabled', name=_('Operation')),
498 FieldSet('parent', 'bridge', name=_('Related Interfaces')),
499 FieldSet(
500 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
501 name=_('802.1Q Switching'),
502 html_id='dot1q-switching',
503 ),
504 )
506 class Meta:
507 model = VMInterface
508 fields = [
509 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
510 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
511 'owner', 'tags',
512 ]
515class VirtualDiskForm(VMComponentForm):
517 fieldsets = (
518 FieldSet('virtual_machine', 'name', 'size', 'description', 'tags', name=_('Disk')),
519 )
521 class Meta:
522 model = VirtualDisk
523 fields = [
524 'virtual_machine', 'name', 'size', 'description', 'owner', 'tags',
525 ]
527 def __init__(self, *args, **kwargs):
528 super().__init__(*args, **kwargs)
530 # Set unit label based on configured DISK_BASE_UNIT (MB vs MiB)
531 self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT))