customfields.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import re
  2. from datetime import datetime, date
  3. import django_filters
  4. from django import forms
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.contrib.postgres.fields import ArrayField
  7. from django.core.validators import RegexValidator, ValidationError
  8. from django.db import models
  9. from django.urls import reverse
  10. from django.utils.html import escape
  11. from django.utils.safestring import mark_safe
  12. from extras.choices import *
  13. from extras.utils import FeatureQuery
  14. from netbox.models import ChangeLoggedModel
  15. from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
  16. from utilities import filters
  17. from utilities.forms import (
  18. CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
  19. LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
  20. )
  21. from utilities.querysets import RestrictedQuerySet
  22. from utilities.validators import validate_regex
  23. __all__ = (
  24. 'CustomField',
  25. 'CustomFieldManager',
  26. )
  27. class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
  28. use_in_migrations = True
  29. def get_for_model(self, model):
  30. """
  31. Return all CustomFields assigned to the given model.
  32. """
  33. content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
  34. return self.get_queryset().filter(content_types=content_type)
  35. class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
  36. content_types = models.ManyToManyField(
  37. to=ContentType,
  38. related_name='custom_fields',
  39. limit_choices_to=FeatureQuery('custom_fields'),
  40. help_text='The object(s) to which this field applies.'
  41. )
  42. type = models.CharField(
  43. max_length=50,
  44. choices=CustomFieldTypeChoices,
  45. default=CustomFieldTypeChoices.TYPE_TEXT,
  46. help_text='The type of data this custom field holds'
  47. )
  48. object_type = models.ForeignKey(
  49. to=ContentType,
  50. on_delete=models.PROTECT,
  51. blank=True,
  52. null=True,
  53. help_text='The type of NetBox object this field maps to (for object fields)'
  54. )
  55. name = models.CharField(
  56. max_length=50,
  57. unique=True,
  58. help_text='Internal field name',
  59. validators=(
  60. RegexValidator(
  61. regex=r'^[a-z0-9_]+$',
  62. message="Only alphanumeric characters and underscores are allowed.",
  63. flags=re.IGNORECASE
  64. ),
  65. )
  66. )
  67. label = models.CharField(
  68. max_length=50,
  69. blank=True,
  70. help_text='Name of the field as displayed to users (if not provided, '
  71. 'the field\'s name will be used)'
  72. )
  73. description = models.CharField(
  74. max_length=200,
  75. blank=True
  76. )
  77. required = models.BooleanField(
  78. default=False,
  79. help_text='If true, this field is required when creating new objects '
  80. 'or editing an existing object.'
  81. )
  82. filter_logic = models.CharField(
  83. max_length=50,
  84. choices=CustomFieldFilterLogicChoices,
  85. default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
  86. help_text='Loose matches any instance of a given string; exact '
  87. 'matches the entire field.'
  88. )
  89. default = models.JSONField(
  90. blank=True,
  91. null=True,
  92. help_text='Default value for the field (must be a JSON value). Encapsulate '
  93. 'strings with double quotes (e.g. "Foo").'
  94. )
  95. weight = models.PositiveSmallIntegerField(
  96. default=100,
  97. help_text='Fields with higher weights appear lower in a form.'
  98. )
  99. validation_minimum = models.IntegerField(
  100. blank=True,
  101. null=True,
  102. verbose_name='Minimum value',
  103. help_text='Minimum allowed value (for numeric fields)'
  104. )
  105. validation_maximum = models.IntegerField(
  106. blank=True,
  107. null=True,
  108. verbose_name='Maximum value',
  109. help_text='Maximum allowed value (for numeric fields)'
  110. )
  111. validation_regex = models.CharField(
  112. blank=True,
  113. validators=[validate_regex],
  114. max_length=500,
  115. verbose_name='Validation regex',
  116. help_text='Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. '
  117. 'For example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
  118. )
  119. choices = ArrayField(
  120. base_field=models.CharField(max_length=100),
  121. blank=True,
  122. null=True,
  123. help_text='Comma-separated list of available choices (for selection fields)'
  124. )
  125. objects = CustomFieldManager()
  126. class Meta:
  127. ordering = ['weight', 'name']
  128. def __str__(self):
  129. return self.label or self.name.replace('_', ' ').capitalize()
  130. def get_absolute_url(self):
  131. return reverse('extras:customfield', args=[self.pk])
  132. def __init__(self, *args, **kwargs):
  133. super().__init__(*args, **kwargs)
  134. # Cache instance's original name so we can check later whether it has changed
  135. self._name = self.name
  136. def populate_initial_data(self, content_types):
  137. """
  138. Populate initial custom field data upon either a) the creation of a new CustomField, or
  139. b) the assignment of an existing CustomField to new object types.
  140. """
  141. for ct in content_types:
  142. model = ct.model_class()
  143. instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
  144. for instance in instances:
  145. instance.custom_field_data[self.name] = self.default
  146. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  147. def remove_stale_data(self, content_types):
  148. """
  149. Delete custom field data which is no longer relevant (either because the CustomField is
  150. no longer assigned to a model, or because it has been deleted).
  151. """
  152. for ct in content_types:
  153. model = ct.model_class()
  154. instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
  155. for instance in instances:
  156. del(instance.custom_field_data[self.name])
  157. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  158. def rename_object_data(self, old_name, new_name):
  159. """
  160. Called when a CustomField has been renamed. Updates all assigned object data.
  161. """
  162. for ct in self.content_types.all():
  163. model = ct.model_class()
  164. params = {f'custom_field_data__{old_name}__isnull': False}
  165. instances = model.objects.filter(**params)
  166. for instance in instances:
  167. instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
  168. model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
  169. def clean(self):
  170. super().clean()
  171. # Validate the field's default value (if any)
  172. if self.default is not None:
  173. try:
  174. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  175. default_value = str(self.default)
  176. else:
  177. default_value = self.default
  178. self.validate(default_value)
  179. except ValidationError as err:
  180. raise ValidationError({
  181. 'default': f'Invalid default value "{self.default}": {err.message}'
  182. })
  183. # Minimum/maximum values can be set only for numeric fields
  184. if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
  185. raise ValidationError({
  186. 'validation_minimum': "A minimum value may be set only for numeric fields"
  187. })
  188. if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
  189. raise ValidationError({
  190. 'validation_maximum': "A maximum value may be set only for numeric fields"
  191. })
  192. # Regex validation can be set only for text fields
  193. regex_types = (
  194. CustomFieldTypeChoices.TYPE_TEXT,
  195. CustomFieldTypeChoices.TYPE_LONGTEXT,
  196. CustomFieldTypeChoices.TYPE_URL,
  197. )
  198. if self.validation_regex and self.type not in regex_types:
  199. raise ValidationError({
  200. 'validation_regex': "Regular expression validation is supported only for text and URL fields"
  201. })
  202. # Choices can be set only on selection fields
  203. if self.choices and self.type not in (
  204. CustomFieldTypeChoices.TYPE_SELECT,
  205. CustomFieldTypeChoices.TYPE_MULTISELECT
  206. ):
  207. raise ValidationError({
  208. 'choices': "Choices may be set only for custom selection fields."
  209. })
  210. # A selection field must have at least two choices defined
  211. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
  212. raise ValidationError({
  213. 'choices': "Selection fields must specify at least two choices."
  214. })
  215. # A selection field's default (if any) must be present in its available choices
  216. if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
  217. raise ValidationError({
  218. 'default': f"The specified default value ({self.default}) is not listed as an available choice."
  219. })
  220. # Object fields must define an object_type; other fields must not
  221. if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
  222. if not self.object_type:
  223. raise ValidationError({
  224. 'object_type': "Object fields must define an object type."
  225. })
  226. elif self.object_type:
  227. raise ValidationError({
  228. 'object_type': f"{self.get_type_display()} fields may not define an object type."
  229. })
  230. def serialize(self, value):
  231. """
  232. Prepare a value for storage as JSON data.
  233. """
  234. if value is None:
  235. return value
  236. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  237. return value.pk
  238. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  239. return [obj.pk for obj in value] or None
  240. return value
  241. def deserialize(self, value):
  242. """
  243. Convert JSON data to a Python object suitable for the field type.
  244. """
  245. if value is None:
  246. return value
  247. if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  248. model = self.object_type.model_class()
  249. return model.objects.filter(pk=value).first()
  250. if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  251. model = self.object_type.model_class()
  252. return model.objects.filter(pk__in=value)
  253. return value
  254. def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
  255. """
  256. Return a form field suitable for setting a CustomField's value for an object.
  257. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
  258. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
  259. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
  260. """
  261. initial = self.default if set_initial else None
  262. required = self.required if enforce_required else False
  263. # Integer
  264. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  265. field = forms.IntegerField(
  266. required=required,
  267. initial=initial,
  268. min_value=self.validation_minimum,
  269. max_value=self.validation_maximum
  270. )
  271. # Boolean
  272. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  273. choices = (
  274. (None, '---------'),
  275. (True, 'True'),
  276. (False, 'False'),
  277. )
  278. field = forms.NullBooleanField(
  279. required=required, initial=initial, widget=StaticSelect(choices=choices)
  280. )
  281. # Date
  282. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  283. field = forms.DateField(required=required, initial=initial, widget=DatePicker())
  284. # Select
  285. elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
  286. choices = [(c, c) for c in self.choices]
  287. default_choice = self.default if self.default in self.choices else None
  288. if not required or default_choice is None:
  289. choices = add_blank_choice(choices)
  290. # Set the initial value to the first available choice (if any)
  291. if set_initial and default_choice:
  292. initial = default_choice
  293. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  294. field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
  295. field = field_class(
  296. choices=choices, required=required, initial=initial, widget=StaticSelect()
  297. )
  298. else:
  299. field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
  300. field = field_class(
  301. choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
  302. )
  303. # URL
  304. elif self.type == CustomFieldTypeChoices.TYPE_URL:
  305. field = LaxURLField(required=required, initial=initial)
  306. # JSON
  307. elif self.type == CustomFieldTypeChoices.TYPE_JSON:
  308. field = forms.JSONField(required=required, initial=initial)
  309. # Object
  310. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  311. model = self.object_type.model_class()
  312. field = DynamicModelChoiceField(
  313. queryset=model.objects.all(),
  314. required=required,
  315. initial=initial
  316. )
  317. # Multiple objects
  318. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  319. model = self.object_type.model_class()
  320. field = DynamicModelMultipleChoiceField(
  321. queryset=model.objects.all(),
  322. required=required,
  323. initial=initial
  324. )
  325. # Text
  326. else:
  327. if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
  328. max_length = None
  329. widget = forms.Textarea
  330. else:
  331. max_length = 255
  332. widget = None
  333. field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget)
  334. if self.validation_regex:
  335. field.validators = [
  336. RegexValidator(
  337. regex=self.validation_regex,
  338. message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>")
  339. )
  340. ]
  341. field.model = self
  342. field.label = str(self)
  343. if self.description:
  344. field.help_text = escape(self.description)
  345. return field
  346. def to_filter(self, lookup_expr=None):
  347. """
  348. Return a django_filters Filter instance suitable for this field type.
  349. :param lookup_expr: Custom lookup expression (optional)
  350. """
  351. kwargs = {
  352. 'field_name': f'custom_field_data__{self.name}'
  353. }
  354. if lookup_expr is not None:
  355. kwargs['lookup_expr'] = lookup_expr
  356. # Text/URL
  357. if self.type in (
  358. CustomFieldTypeChoices.TYPE_TEXT,
  359. CustomFieldTypeChoices.TYPE_LONGTEXT,
  360. CustomFieldTypeChoices.TYPE_URL,
  361. ):
  362. filter_class = filters.MultiValueCharFilter
  363. if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
  364. kwargs['lookup_expr'] = 'icontains'
  365. # Integer
  366. elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  367. filter_class = filters.MultiValueNumberFilter
  368. # Boolean
  369. elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
  370. filter_class = django_filters.BooleanFilter
  371. # Date
  372. elif self.type == CustomFieldTypeChoices.TYPE_DATE:
  373. filter_class = filters.MultiValueDateFilter
  374. # Select
  375. elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
  376. filter_class = filters.MultiValueCharFilter
  377. # Multiselect
  378. elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  379. filter_class = filters.MultiValueCharFilter
  380. kwargs['lookup_expr'] = 'has_key'
  381. # Object
  382. elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
  383. filter_class = filters.MultiValueNumberFilter
  384. # Multi-object
  385. elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  386. filter_class = filters.MultiValueNumberFilter
  387. kwargs['lookup_expr'] = 'contains'
  388. # Unsupported custom field type
  389. else:
  390. return None
  391. filter_instance = filter_class(**kwargs)
  392. filter_instance.custom_field = self
  393. return filter_instance
  394. def validate(self, value):
  395. """
  396. Validate a value according to the field's type validation rules.
  397. """
  398. if value not in [None, '']:
  399. # Validate text field
  400. if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT):
  401. if type(value) is not str:
  402. raise ValidationError(f"Value must be a string.")
  403. if self.validation_regex and not re.match(self.validation_regex, value):
  404. raise ValidationError(f"Value must match regex '{self.validation_regex}'")
  405. # Validate integer
  406. if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
  407. if type(value) is not int:
  408. raise ValidationError("Value must be an integer.")
  409. if self.validation_minimum is not None and value < self.validation_minimum:
  410. raise ValidationError(f"Value must be at least {self.validation_minimum}")
  411. if self.validation_maximum is not None and value > self.validation_maximum:
  412. raise ValidationError(f"Value must not exceed {self.validation_maximum}")
  413. # Validate boolean
  414. if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
  415. raise ValidationError("Value must be true or false.")
  416. # Validate date
  417. if self.type == CustomFieldTypeChoices.TYPE_DATE:
  418. if type(value) is not date:
  419. try:
  420. datetime.strptime(value, '%Y-%m-%d')
  421. except ValueError:
  422. raise ValidationError("Date values must be in the format YYYY-MM-DD.")
  423. # Validate selected choice
  424. if self.type == CustomFieldTypeChoices.TYPE_SELECT:
  425. if value not in self.choices:
  426. raise ValidationError(
  427. f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
  428. )
  429. # Validate all selected choices
  430. if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  431. if not set(value).issubset(self.choices):
  432. raise ValidationError(
  433. f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
  434. )
  435. elif self.required:
  436. raise ValidationError("Required field cannot be empty.")