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

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 _ 

7 

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) 

35 

36from ..models import * 

37 

38__all__ = ( 

39 'ClusterAddDevicesForm', 

40 'ClusterForm', 

41 'ClusterGroupForm', 

42 'ClusterRemoveDevicesForm', 

43 'ClusterTypeForm', 

44 'VMInterfaceForm', 

45 'VirtualDiskForm', 

46 'VirtualMachineForm', 

47 'VirtualMachineTypeForm', 

48) 

49 

50 

51class ClusterTypeForm(OrganizationalModelForm): 

52 fieldsets = ( 

53 FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Type')), 

54 ) 

55 

56 class Meta: 

57 model = ClusterType 

58 fields = ( 

59 'name', 'slug', 'description', 'owner', 'comments', 'tags', 

60 ) 

61 

62 

63class ClusterGroupForm(OrganizationalModelForm): 

64 fieldsets = ( 

65 FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Group')), 

66 ) 

67 

68 class Meta: 

69 model = ClusterGroup 

70 fields = ( 

71 'name', 'slug', 'description', 'owner', 'comments', 'tags', 

72 ) 

73 

74 

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 ) 

92 

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 ) 

98 

99 class Meta: 

100 model = Cluster 

101 fields = ( 

102 'name', 'type', 'group', 'status', 'tenant', 'description', 'owner', 'comments', 'tags', 

103 ) 

104 

105 

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 ) 

146 

147 class Meta: 

148 fields = [ 

149 'region', 'site', 'rack', 'devices', 

150 ] 

151 

152 def __init__(self, cluster, *args, **kwargs): 

153 

154 self.cluster = cluster 

155 

156 super().__init__(*args, **kwargs) 

157 

158 self.fields['devices'].choices = [] 

159 

160 def clean(self): 

161 super().clean() 

162 

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 }) 

183 

184 

185class ClusterRemoveDevicesForm(ConfirmationForm): 

186 pk = forms.ModelMultipleChoiceField( 

187 queryset=Device.objects.all(), 

188 widget=forms.MultipleHiddenInput() 

189 ) 

190 

191 

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 ) 

200 

201 fieldsets = ( 

202 FieldSet('name', 'slug', 'description', 'tags', name=_('Virtual Machine Type')), 

203 FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')), 

204 ) 

205 

206 class Meta: 

207 model = VirtualMachineType 

208 fields = ( 

209 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description', 

210 'owner', 'comments', 'tags', 

211 ) 

212 

213 def __init__(self, *args, **kwargs): 

214 super().__init__(*args, **kwargs) 

215 

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 ) 

220 

221 

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 ) 

298 

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 ) 

310 

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 ] 

318 

319 def __init__(self, *args, **kwargs): 

320 super().__init__(*args, **kwargs) 

321 

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)) 

325 

326 # Populate virtual machine type defaults, if any 

327 self._populate_virtual_machine_type_defaults() 

328 

329 if self.instance.pk: 

330 

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.") 

335 

336 # Compile list of choices for primary IPv4 and IPv6 addresses 

337 for family in [4, 6]: 

338 ip_choices = [(None, '---------')] 

339 

340 # Gather PKs of all interfaces belonging to this VM 

341 interface_ids = self.instance.interfaces.values_list('pk', flat=True) 

342 

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 

362 

363 else: 

364 

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') 

368 

369 def _populate_virtual_machine_type_defaults(self): 

370 """ 

371 Populate platform/vCPUs/memory from the selected VirtualMachineType. 

372 

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 

378 

379 is_new = not self.instance.pk 

380 

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 

384 

385 if not (virtual_machine_type := VirtualMachineType.objects.filter(pk=virtual_machine_type_id).first()): 

386 return 

387 

388 defaults = { 

389 'platform': ('default_platform_id', 'platform_id'), 

390 'vcpus': ('default_vcpus', 'vcpus'), 

391 'memory': ('default_memory', 'memory'), 

392 } 

393 

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 

398 

399 

400# 

401# Virtual machine components 

402# 

403 

404class VMComponentForm(OwnerMixin, NetBoxModelForm): 

405 virtual_machine = DynamicModelChoiceField( 

406 label=_('Virtual machine'), 

407 queryset=VirtualMachine.objects.all(), 

408 selector=True 

409 ) 

410 

411 def __init__(self, *args, **kwargs): 

412 super().__init__(*args, **kwargs) 

413 

414 # Disable reassignment of VirtualMachine when editing an existing instance 

415 if self.instance.pk: 

416 self.fields['virtual_machine'].disabled = True 

417 

418 

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 ) 

493 

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 ) 

505 

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 ] 

513 

514 

515class VirtualDiskForm(VMComponentForm): 

516 

517 fieldsets = ( 

518 FieldSet('virtual_machine', 'name', 'size', 'description', 'tags', name=_('Disk')), 

519 ) 

520 

521 class Meta: 

522 model = VirtualDisk 

523 fields = [ 

524 'virtual_machine', 'name', 'size', 'description', 'owner', 'tags', 

525 ] 

526 

527 def __init__(self, *args, **kwargs): 

528 super().__init__(*args, **kwargs) 

529 

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))