import decimal import re from datetime import datetime, date import django_filters from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from extras.choices import * from extras.data import CHOICE_SETS from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.search import FieldTypes from utilities import filters from utilities.forms.fields import ( CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField, ) from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.querysets import RestrictedQuerySet from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import validate_regex __all__ = ( 'CustomField', 'CustomFieldChoiceSet', 'CustomFieldManager', ) SEARCH_TYPES = { CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING, CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING, CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT, CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING, CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING, } class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True def get_for_model(self, model): """ Return all CustomFields assigned to the given model. """ content_type = ContentType.objects.get_for_model(model._meta.concrete_model) return self.get_queryset().filter(content_types=content_type) class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', limit_choices_to=FeatureQuery('custom_fields'), help_text=_('The object(s) to which this field applies.') ) type = models.CharField( verbose_name=_('type'), max_length=50, choices=CustomFieldTypeChoices, default=CustomFieldTypeChoices.TYPE_TEXT, help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( to=ContentType, on_delete=models.PROTECT, blank=True, null=True, help_text=_('The type of NetBox object this field maps to (for object fields)') ) name = models.CharField( verbose_name=_('name'), max_length=50, unique=True, help_text=_('Internal field name'), validators=( RegexValidator( regex=r'^[a-z0-9_]+$', message=_("Only alphanumeric characters and underscores are allowed."), flags=re.IGNORECASE ), RegexValidator( regex=r'__', message=_("Double underscores are not permitted in custom field names."), flags=re.IGNORECASE, inverse_match=True ), ) ) label = models.CharField( verbose_name=_('label'), max_length=50, blank=True, help_text=_( "Name of the field as displayed to users (if not provided, 'the field's name will be used)" ) ) group_name = models.CharField( verbose_name=_('group name'), max_length=50, blank=True, help_text=_("Custom fields within the same group will be displayed together") ) description = models.CharField( verbose_name=_('description'), max_length=200, blank=True ) required = models.BooleanField( verbose_name=_('required'), default=False, help_text=_("If true, this field is required when creating new objects or editing an existing object.") ) search_weight = models.PositiveSmallIntegerField( verbose_name=_('search weight'), default=1000, help_text=_( "Weighting for search. Lower values are considered more important. Fields with a search weight of zero " "will be ignored." ) ) filter_logic = models.CharField( verbose_name=_('filter logic'), max_length=50, choices=CustomFieldFilterLogicChoices, default=CustomFieldFilterLogicChoices.FILTER_LOOSE, help_text=_("Loose matches any instance of a given string; exact matches the entire field.") ) default = models.JSONField( verbose_name=_('default'), blank=True, null=True, help_text=_( 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").' ) ) weight = models.PositiveSmallIntegerField( default=100, verbose_name=_('display weight'), help_text=_('Fields with higher weights appear lower in a form.') ) validation_minimum = models.IntegerField( blank=True, null=True, verbose_name=_('minimum value'), help_text=_('Minimum allowed value (for numeric fields)') ) validation_maximum = models.IntegerField( blank=True, null=True, verbose_name=_('maximum value'), help_text=_('Maximum allowed value (for numeric fields)') ) validation_regex = models.CharField( blank=True, validators=[validate_regex], max_length=500, verbose_name=_('validation regex'), help_text=_( 'Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. For ' 'example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' ) ) choice_set = models.ForeignKey( to='CustomFieldChoiceSet', on_delete=models.PROTECT, related_name='choices_for', verbose_name=_('choice set'), blank=True, null=True ) ui_visibility = models.CharField( max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, verbose_name=_('UI visibility'), help_text=_('Specifies the visibility of custom field in the UI') ) is_cloneable = models.BooleanField( default=False, verbose_name=_('is cloneable'), help_text=_('Replicate this value when cloning objects') ) objects = CustomFieldManager() clone_fields = ( 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'ui_visibility', 'is_cloneable', ) class Meta: ordering = ['group_name', 'weight', 'name'] verbose_name = _('custom field') verbose_name_plural = _('custom fields') def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() def get_absolute_url(self): return reverse('extras:customfield', args=[self.pk]) @property def docs_url(self): return f'{settings.STATIC_URL}docs/models/extras/customfield/' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Cache instance's original name so we can check later whether it has changed self._name = self.__dict__.get('name') @property def search_type(self): return SEARCH_TYPES.get(self.type) @property def choices(self): if self.choice_set: return self.choice_set.choices return [] def get_choice_label(self, value): if not hasattr(self, '_choice_map'): self._choice_map = dict(self.choices) return self._choice_map.get(value, value) def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or b) the assignment of an existing CustomField to new object types. """ for ct in content_types: model = ct.model_class() instances = model.objects.exclude(**{f'custom_field_data__contains': self.name}) for instance in instances: instance.custom_field_data[self.name] = self.default model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) def remove_stale_data(self, content_types): """ Delete custom field data which is no longer relevant (either because the CustomField is no longer assigned to a model, or because it has been deleted). """ for ct in content_types: model = ct.model_class() instances = model.objects.filter(custom_field_data__has_key=self.name) for instance in instances: del instance.custom_field_data[self.name] model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) def rename_object_data(self, old_name, new_name): """ Called when a CustomField has been renamed. Updates all assigned object data. """ for ct in self.content_types.all(): model = ct.model_class() params = {f'custom_field_data__{old_name}__isnull': False} instances = model.objects.filter(**params) for instance in instances: instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name) model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) def clean(self): super().clean() # Validate the field's default value (if any) if self.default is not None: try: if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): default_value = str(self.default) else: default_value = self.default self.validate(default_value) except ValidationError as err: raise ValidationError({ 'default': _( 'Invalid default value "{default}": {message}' ).format(default=self.default, message=err.message) }) # Minimum/maximum values can be set only for numeric fields if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): if self.validation_minimum: raise ValidationError({'validation_minimum': _("A minimum value may be set only for numeric fields")}) if self.validation_maximum: raise ValidationError({'validation_maximum': _("A maximum value may be set only for numeric fields")}) # Regex validation can be set only for text fields regex_types = ( CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_URL, ) if self.validation_regex and self.type not in regex_types: raise ValidationError({ 'validation_regex': _("Regular expression validation is supported only for text and URL fields") }) # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT ): if not self.choice_set: raise ValidationError({ 'choice_set': _("Selection fields must specify a set of choices.") }) elif self.choice_set: raise ValidationError({ 'choice_set': _("Choices may be set only on selection fields.") }) # Object fields must define an object_type; other fields must not if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): if not self.object_type: raise ValidationError({ 'object_type': _("Object fields must define an object type.") }) elif self.object_type: raise ValidationError({ 'object_type': _( "{type_display} fields may not define an object type.") .format(type_display=self.get_type_display()) }) def serialize(self, value): """ Prepare a value for storage as JSON data. """ if value is None: return value if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date: return value.isoformat() if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime: return value.isoformat() if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: return [obj.pk for obj in value] or None return value def deserialize(self, value): """ Convert JSON data to a Python object suitable for the field type. """ if value is None: return value if self.type == CustomFieldTypeChoices.TYPE_DATE: try: return date.fromisoformat(value) except ValueError: return value if self.type == CustomFieldTypeChoices.TYPE_DATETIME: try: return datetime.fromisoformat(value) except ValueError: return value if self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() return model.objects.filter(pk=value).first() if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: model = self.object_type.model_class() return model.objects.filter(pk__in=value) return value def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None required = self.required if enforce_required else False # Integer if self.type == CustomFieldTypeChoices.TYPE_INTEGER: field = forms.IntegerField( required=required, initial=initial, min_value=self.validation_minimum, max_value=self.validation_maximum ) # Decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: field = forms.DecimalField( required=required, initial=initial, max_digits=12, decimal_places=4, min_value=self.validation_minimum, max_value=self.validation_maximum ) # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( (None, '---------'), (True, _('True')), (False, _('False')), ) field = forms.NullBooleanField( required=required, initial=initial, widget=forms.Select(choices=choices) ) # Date elif self.type == CustomFieldTypeChoices.TYPE_DATE: field = forms.DateField(required=required, initial=initial, widget=DatePicker()) # Date & time elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker()) # Select elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT): choices = self.choice_set.choices default_choice = self.default if self.default in self.choices else None if not required or default_choice is None: choices = add_blank_choice(choices) # Set the initial value to the first available choice (if any) if set_initial and default_choice: initial = default_choice if for_csv_import: if self.type == CustomFieldTypeChoices.TYPE_SELECT: field_class = CSVChoiceField else: field_class = CSVMultipleChoiceField field = field_class(choices=choices, required=required, initial=initial) else: if self.type == CustomFieldTypeChoices.TYPE_SELECT: field_class = DynamicChoiceField widget_class = APISelect else: field_class = DynamicMultipleChoiceField widget_class = APISelectMultiple field = field_class( choices=choices, required=required, initial=initial, widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/') ) # URL elif self.type == CustomFieldTypeChoices.TYPE_URL: field = LaxURLField(required=required, initial=initial) # JSON elif self.type == CustomFieldTypeChoices.TYPE_JSON: field = JSONField(required=required, initial=initial) # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField field = field_class( queryset=model.objects.all(), required=required, initial=initial ) # Multiple objects elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: model = self.object_type.model_class() field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField field = field_class( queryset=model.objects.all(), required=required, initial=initial, ) # Text else: widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None field = forms.CharField(required=required, initial=initial, widget=widget) if self.validation_regex: field.validators = [ RegexValidator( regex=self.validation_regex, message=mark_safe(_("Values must match this regex: {regex}").format( regex=self.validation_regex )) ) ] field.model = self field.label = str(self) if self.description: field.help_text = render_markdown(self.description) # Annotate read-only fields if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: field.disabled = True prepend = '
' if field.help_text else '' field.help_text += f'{prepend} ' + _('Field is set to read-only.') return field def to_filter(self, lookup_expr=None): """ Return a django_filters Filter instance suitable for this field type. :param lookup_expr: Custom lookup expression (optional) """ kwargs = { 'field_name': f'custom_field_data__{self.name}' } if lookup_expr is not None: kwargs['lookup_expr'] = lookup_expr # Text/URL if self.type in ( CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_URL, ): filter_class = filters.MultiValueCharFilter if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: kwargs['lookup_expr'] = 'icontains' # Integer elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: filter_class = filters.MultiValueNumberFilter # Decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: filter_class = filters.MultiValueDecimalFilter # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: filter_class = django_filters.BooleanFilter # Date elif self.type == CustomFieldTypeChoices.TYPE_DATE: filter_class = filters.MultiValueDateFilter # Date & time elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: filter_class = filters.MultiValueDateTimeFilter # Select elif self.type == CustomFieldTypeChoices.TYPE_SELECT: filter_class = filters.MultiValueCharFilter # Multiselect elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: filter_class = filters.MultiValueCharFilter kwargs['lookup_expr'] = 'has_key' # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: filter_class = filters.MultiValueNumberFilter # Multi-object elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: filter_class = filters.MultiValueNumberFilter kwargs['lookup_expr'] = 'contains' # Unsupported custom field type else: return None filter_instance = filter_class(**kwargs) filter_instance.custom_field = self return filter_instance def validate(self, value): """ Validate a value according to the field's type validation rules. """ if value not in [None, '']: # Validate text field if self.type in (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT): if type(value) is not str: raise ValidationError(_("Value must be a string.")) if self.validation_regex and not re.match(self.validation_regex, value): raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex)) # Validate integer elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: if type(value) is not int: raise ValidationError(_("Value must be an integer.")) if self.validation_minimum is not None and value < self.validation_minimum: raise ValidationError( _("Value must be at least {minimum}").format(minimum=self.validation_maximum) ) if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError( _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) ) # Validate decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: try: decimal.Decimal(value) except decimal.InvalidOperation: raise ValidationError(_("Value must be a decimal.")) if self.validation_minimum is not None and value < self.validation_minimum: raise ValidationError( _("Value must be at least {minimum}").format(minimum=self.validation_minimum) ) if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError( _("Value must not exceed {maximum}").format(maximum=self.validation_maximum) ) # Validate boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError(_("Value must be true or false.")) # Validate date elif self.type == CustomFieldTypeChoices.TYPE_DATE: if type(value) is not date: try: date.fromisoformat(value) except ValueError: raise ValidationError(_("Date values must be in ISO 8601 format (YYYY-MM-DD).")) # Validate date & time elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: if type(value) is not datetime: try: datetime.fromisoformat(value) except ValueError: raise ValidationError( _("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).") ) # Validate selected choice elif self.type == CustomFieldTypeChoices.TYPE_SELECT: if value not in self.choice_set.values: raise ValidationError( _("Invalid choice ({value}) for choice set {choiceset}.").format( value=value, choiceset=self.choice_set ) ) # Validate all selected choices elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if not set(value).issubset(self.choice_set.values): raise ValidationError( _("Invalid choice(s) ({value}) for choice set {choiceset}.").format( value=value, choiceset=self.choice_set ) ) # Validate selected object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: if type(value) is not int: raise ValidationError(_("Value must be an object ID, not {type}").format(type=type(value).__name__)) # Validate selected objects elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: if type(value) is not list: raise ValidationError( _("Value must be a list of object IDs, not {type}").format(type=type(value).__name__) ) for id in value: if type(id) is not int: raise ValidationError(_("Found invalid object ID: {id}").format(id=id)) elif self.required: raise ValidationError(_("Required field cannot be empty.")) class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ Represents a set of choices available for choice and multi-choice custom fields. """ name = models.CharField( max_length=100, unique=True ) description = models.CharField( max_length=200, blank=True ) base_choices = models.CharField( max_length=50, choices=CustomFieldChoiceSetBaseChoices, blank=True, help_text=_('Base set of predefined choices (optional)') ) extra_choices = ArrayField( ArrayField( base_field=models.CharField(max_length=100), size=2 ), blank=True, null=True ) order_alphabetically = models.BooleanField( default=False, help_text=_('Choices are automatically ordered alphabetically') ) clone_fields = ('extra_choices', 'order_alphabetically') class Meta: ordering = ('name',) verbose_name = _('custom field choice set') verbose_name_plural = _('custom field choice sets') def __str__(self): return self.name def get_absolute_url(self): return reverse('extras:customfieldchoiceset', args=[self.pk]) @property def choices(self): """ Returns a concatenation of the base and extra choices. """ if not hasattr(self, '_choices'): self._choices = [] if self.base_choices: self._choices.extend(CHOICE_SETS.get(self.base_choices)) if self.extra_choices: self._choices.extend(self.extra_choices) if self.order_alphabetically: self._choices = sorted(self._choices, key=lambda x: x[0]) return self._choices @property def choices_count(self): return len(self.choices) @property def values(self): """ Returns an iterator of the valid choice values. """ return (x[0] for x in self.choices) def clean(self): if not self.base_choices and not self.extra_choices: raise ValidationError(_("Must define base or extra choices.")) def save(self, *args, **kwargs): # Sort choices if alphabetical ordering is enforced if self.order_alphabetically: self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0]) return super().save(*args, **kwargs)