mixins.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import warnings
  2. from django import forms
  3. from django.conf import settings
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.core.exceptions import ObjectDoesNotExist, ValidationError
  6. from django.db import connection
  7. from django.db.models.signals import post_save
  8. from django.utils.translation import gettext_lazy as _
  9. from dcim.constants import LOCATION_SCOPE_TYPES
  10. from dcim.models import PortMapping, PortTemplateMapping, Site
  11. from utilities.forms import get_field_value
  12. from utilities.forms.fields import (
  13. ContentTypeChoiceField,
  14. CSVContentTypeField,
  15. DynamicModelChoiceField,
  16. )
  17. from utilities.forms.widgets import HTMXSelect
  18. from utilities.templatetags.builtins.filters import bettertitle
  19. __all__ = (
  20. 'FrontPortFormMixin',
  21. 'ScopedBulkEditForm',
  22. 'ScopedForm',
  23. 'ScopedImportForm',
  24. )
  25. class ScopedForm(forms.Form):
  26. scope_type = ContentTypeChoiceField(
  27. queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
  28. # hx_target_id='scope' — all ScopedForm consumers must declare a FieldSet with html_id='scope'
  29. widget=HTMXSelect(hx_target_id='scope'),
  30. required=False,
  31. label=_('Scope type')
  32. )
  33. scope = DynamicModelChoiceField(
  34. label=_('Scope'),
  35. queryset=Site.objects.none(), # Initial queryset
  36. required=False,
  37. disabled=True,
  38. selector=True
  39. )
  40. def __init__(self, *args, **kwargs):
  41. instance = kwargs.get('instance')
  42. initial = kwargs.get('initial', {})
  43. if instance is not None and instance.scope:
  44. initial['scope'] = instance.scope
  45. kwargs['initial'] = initial
  46. super().__init__(*args, **kwargs)
  47. self._set_scoped_values()
  48. if settings.DEBUG:
  49. has_scope_fieldset = any(
  50. getattr(fs, 'html_id', None) == 'scope'
  51. for fs in getattr(self, 'fieldsets', [])
  52. )
  53. if not has_scope_fieldset:
  54. warnings.warn(
  55. f"{self.__class__.__name__} uses ScopedForm but declares no "
  56. "FieldSet with html_id='scope'; HTMX partial swap will fail silently.",
  57. stacklevel=2,
  58. )
  59. def clean(self):
  60. super().clean()
  61. scope = self.cleaned_data.get('scope')
  62. scope_type = self.cleaned_data.get('scope_type')
  63. if scope_type and not scope:
  64. raise ValidationError({
  65. 'scope': _(
  66. "Please select a {scope_type}."
  67. ).format(scope_type=scope_type.model_class()._meta.model_name)
  68. })
  69. # Assign the selected scope (if any)
  70. self.instance.scope = scope
  71. def _set_scoped_values(self):
  72. if scope_type_id := get_field_value(self, 'scope_type'):
  73. try:
  74. scope_type = ContentType.objects.get(pk=scope_type_id)
  75. model = scope_type.model_class()
  76. self.fields['scope'].queryset = model.objects.all()
  77. self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
  78. self.fields['scope'].disabled = False
  79. self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
  80. except ObjectDoesNotExist:
  81. pass
  82. if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
  83. self.initial['scope'] = None
  84. else:
  85. # Clear the initial scope value if scope_type is not set
  86. self.initial['scope'] = None
  87. class ScopedBulkEditForm(forms.Form):
  88. scope_type = ContentTypeChoiceField(
  89. queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
  90. widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
  91. required=False,
  92. label=_('Scope type')
  93. )
  94. scope = DynamicModelChoiceField(
  95. label=_('Scope'),
  96. queryset=Site.objects.none(), # Initial queryset
  97. required=False,
  98. disabled=True,
  99. selector=True
  100. )
  101. def __init__(self, *args, **kwargs):
  102. super().__init__(*args, **kwargs)
  103. if scope_type_id := get_field_value(self, 'scope_type'):
  104. try:
  105. scope_type = ContentType.objects.get(pk=scope_type_id)
  106. model = scope_type.model_class()
  107. self.fields['scope'].queryset = model.objects.all()
  108. self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
  109. self.fields['scope'].disabled = False
  110. self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
  111. except ObjectDoesNotExist:
  112. pass
  113. class ScopedImportForm(forms.Form):
  114. scope_type = CSVContentTypeField(
  115. queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
  116. required=False,
  117. label=_('Scope type (app & model)')
  118. )
  119. scope_name = forms.CharField(
  120. required=False,
  121. label=_('Scope name'),
  122. help_text=_('Name of the assigned scope object (if not using ID)')
  123. )
  124. def clean(self):
  125. super().clean()
  126. scope_id = self.cleaned_data.get('scope_id')
  127. scope_name = self.cleaned_data.get('scope_name')
  128. scope_type = self.cleaned_data.get('scope_type')
  129. # Cannot specify both scope_name and scope_id
  130. if scope_name and scope_id:
  131. raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
  132. # Must specify scope_type with scope_name or scope_id
  133. if scope_name and not scope_type:
  134. raise ValidationError(_("scope_type must be specified when using scope_name"))
  135. if scope_id and not scope_type:
  136. raise ValidationError(_("scope_type must be specified when using scope_id"))
  137. # Look up the scope object by name
  138. if scope_type and scope_name:
  139. model = scope_type.model_class()
  140. try:
  141. scope_obj = model.objects.get(name=scope_name)
  142. except model.DoesNotExist:
  143. raise ValidationError({
  144. 'scope_name': _('{scope_type} "{name}" not found.').format(
  145. scope_type=bettertitle(model._meta.verbose_name),
  146. name=scope_name
  147. )
  148. })
  149. except model.MultipleObjectsReturned:
  150. raise ValidationError({
  151. 'scope_name': _(
  152. 'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
  153. ).format(
  154. scope_type=bettertitle(model._meta.verbose_name),
  155. name=scope_name,
  156. )
  157. })
  158. self.cleaned_data['scope_id'] = scope_obj.pk
  159. elif scope_type and not scope_id:
  160. raise ValidationError({
  161. 'scope_id': _(
  162. "Please select a {scope_type}."
  163. ).format(scope_type=scope_type.model_class()._meta.model_name)
  164. })
  165. class FrontPortFormMixin(forms.Form):
  166. rear_ports = forms.MultipleChoiceField(
  167. choices=[],
  168. label=_('Rear ports'),
  169. widget=forms.SelectMultiple(attrs={'size': 8})
  170. )
  171. def clean(self):
  172. super().clean()
  173. # Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
  174. # mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
  175. # FrontPorts being creation by the number of positions.
  176. positions = self.cleaned_data['positions']
  177. frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
  178. rearport_count = len(self.cleaned_data['rear_ports'])
  179. if frontport_count * positions != rearport_count:
  180. raise forms.ValidationError({
  181. 'rear_ports': _(
  182. "The total number of front port positions ({frontport_count}) must match the selected number of "
  183. "rear port positions ({rearport_count})."
  184. ).format(
  185. frontport_count=frontport_count,
  186. rearport_count=rearport_count
  187. )
  188. })
  189. def _save_m2m(self):
  190. super()._save_m2m()
  191. # TODO: Can this be made more efficient?
  192. # Delete existing rear port mappings
  193. self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
  194. # Create new rear port mappings
  195. mappings = []
  196. if self.port_mapping_model is PortTemplateMapping:
  197. params = {
  198. 'device_type_id': self.instance.device_type_id,
  199. 'module_type_id': self.instance.module_type_id,
  200. }
  201. else:
  202. params = {
  203. 'device_id': self.instance.device_id,
  204. }
  205. for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
  206. rear_port_id, rear_port_position = rp_position.split(':')
  207. mappings.append(
  208. self.port_mapping_model(**{
  209. **params,
  210. 'front_port_id': self.instance.pk,
  211. 'front_port_position': i,
  212. 'rear_port_id': rear_port_id,
  213. 'rear_port_position': rear_port_position,
  214. })
  215. )
  216. self.port_mapping_model.objects.bulk_create(mappings)
  217. # Send post_save signals
  218. for mapping in mappings:
  219. post_save.send(
  220. sender=PortMapping,
  221. instance=mapping,
  222. created=True,
  223. raw=False,
  224. using=connection,
  225. update_fields=None
  226. )
  227. def _get_rear_port_choices(self, parent_filter, front_port):
  228. """
  229. Return a list of choices representing each available rear port & position pair on the parent object (identified
  230. by a Q filter), excluding those assigned to the specified instance.
  231. """
  232. occupied_rear_port_positions = [
  233. f'{mapping.rear_port_id}:{mapping.rear_port_position}'
  234. for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
  235. ]
  236. choices = []
  237. for rear_port in self.rear_port_model.objects.filter(parent_filter):
  238. for i in range(1, rear_port.positions + 1):
  239. pair_id = f'{rear_port.pk}:{i}'
  240. if pair_id not in occupied_rear_port_positions:
  241. pair_label = f'{rear_port.name}:{i}'
  242. choices.append((pair_id, pair_label))
  243. return choices