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
« 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 _
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 *
13__all__ = (
14 'Cluster',
15 'ClusterGroup',
16 'ClusterType',
17)
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')
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 )
41 class Meta:
42 ordering = ('name',)
43 verbose_name = _('cluster group')
44 verbose_name_plural = _('cluster groups')
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 )
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 )
91 clone_fields = (
92 'scope', 'type', 'group', 'status', 'tenant',
93 )
94 prerequisite_models = (
95 'virtualization.ClusterType',
96 )
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')
119 def __str__(self):
120 return self.name
122 def get_status_color(self):
123 return ClusterStatusChoices.colors.get(self.status)
125 def clean(self):
126 super().clean()
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
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 })