Coverage for virtualization/models/clusters.py: 55%

59 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from django.apps import apps 

2from django.contrib.contenttypes.fields import GenericRelation 

3from django.core.exceptions import ValidationError 

4from django.db import models 

5from django.utils.translation import gettext_lazy as _ 

6 

7from dcim.models import Device 

8from dcim.models.mixins import CachedScopeMixin 

9from netbox.models import OrganizationalModel, PrimaryModel 

10from netbox.models.features import ContactsMixin 

11from virtualization.choices import * 

12 

13__all__ = ( 

14 'Cluster', 

15 'ClusterGroup', 

16 'ClusterType', 

17) 

18 

19 

20class ClusterType(OrganizationalModel): 

21 """ 

22 A type of Cluster. 

23 """ 

24 class Meta: 

25 ordering = ('name',) 

26 verbose_name = _('cluster type') 

27 verbose_name_plural = _('cluster types') 

28 

29 

30class ClusterGroup(ContactsMixin, OrganizationalModel): 

31 """ 

32 An organizational group of Clusters. 

33 """ 

34 vlan_groups = GenericRelation( 

35 to='ipam.VLANGroup', 

36 content_type_field='scope_type', 

37 object_id_field='scope_id', 

38 related_query_name='cluster_group' 

39 ) 

40 

41 class Meta: 

42 ordering = ('name',) 

43 verbose_name = _('cluster group') 

44 verbose_name_plural = _('cluster groups') 

45 

46 

47class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): 

48 """ 

49 A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. 

50 """ 

51 name = models.CharField( 

52 verbose_name=_('name'), 

53 max_length=100, 

54 db_collation="natural_sort" 

55 ) 

56 type = models.ForeignKey( 

57 verbose_name=_('type'), 

58 to=ClusterType, 

59 on_delete=models.PROTECT, 

60 related_name='clusters' 

61 ) 

62 group = models.ForeignKey( 

63 to=ClusterGroup, 

64 on_delete=models.PROTECT, 

65 related_name='clusters', 

66 blank=True, 

67 null=True 

68 ) 

69 status = models.CharField( 

70 verbose_name=_('status'), 

71 max_length=50, 

72 choices=ClusterStatusChoices, 

73 default=ClusterStatusChoices.STATUS_ACTIVE 

74 ) 

75 tenant = models.ForeignKey( 

76 to='tenancy.Tenant', 

77 on_delete=models.PROTECT, 

78 related_name='clusters', 

79 blank=True, 

80 null=True 

81 ) 

82 

83 # Generic relations 

84 vlan_groups = GenericRelation( 

85 to='ipam.VLANGroup', 

86 content_type_field='scope_type', 

87 object_id_field='scope_id', 

88 related_query_name='cluster' 

89 ) 

90 

91 clone_fields = ( 

92 'scope', 'type', 'group', 'status', 'tenant', 

93 ) 

94 prerequisite_models = ( 

95 'virtualization.ClusterType', 

96 ) 

97 

98 class Meta: 

99 ordering = ['name'] 

100 indexes = ( 

101 models.Index(fields=('name',)), # Default ordering 

102 ) 

103 constraints = ( 

104 models.UniqueConstraint( 

105 fields=('group', 'name'), 

106 name='%(app_label)s_%(class)s_unique_group_name' 

107 ), 

108 models.UniqueConstraint( 

109 fields=('_site', 'name'), 

110 name='%(app_label)s_%(class)s_unique__site_name' 

111 ), 

112 ) 

113 indexes = ( 

114 models.Index(fields=('scope_type', 'scope_id')), 

115 ) 

116 verbose_name = _('cluster') 

117 verbose_name_plural = _('clusters') 

118 

119 def __str__(self): 

120 return self.name 

121 

122 def get_status_color(self): 

123 return ClusterStatusChoices.colors.get(self.status) 

124 

125 def clean(self): 

126 super().clean() 

127 

128 site = location = None 

129 if self.scope_type: 

130 scope_type = self.scope_type.model_class() 

131 if scope_type == apps.get_model('dcim', 'site'): 

132 site = self.scope 

133 elif scope_type == apps.get_model('dcim', 'location'): 

134 location = self.scope 

135 site = location.site 

136 

137 # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. 

138 if not self._state.adding: 

139 if site: 

140 if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count(): 

141 raise ValidationError({ 

142 'scope': _( 

143 "{count} devices are assigned as hosts for this cluster but are not in site {site}" 

144 ).format(count=nonsite_devices, site=site) 

145 }) 

146 if location: 

147 if nonlocation_devices := Device.objects.filter(cluster=self).exclude(location=location).count(): 

148 raise ValidationError({ 

149 'scope': _( 

150 "{count} devices are assigned as hosts for this cluster but are not in location {location}" 

151 ).format(count=nonlocation_devices, location=location) 

152 })