python/netbox-community/netbox/netbox/extras/forms.py

forms.py
from collections import OrderedDict

from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from taggit.forms import TagField

from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
    CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
    SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from .constants import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag


#
# Custom fields
#

def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
    """
    Retrieve all CustomFields applicable to the given ContentType
    """
    field_dict = OrderedDict()
    custom_fields = CustomField.objects.filter(obj_type=content_type)
    if filterable_only:
        custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)

    for cf in custom_fields:
        field_name = 'cf_{}'.format(str(cf.name))
        initial = cf.default if not bulk_edit else None

        # Integer
        if cf.type == CF_TYPE_INTEGER:
            field = forms.IntegerField(required=cf.required, initial=initial)

        # Boolean
        elif cf.type == CF_TYPE_BOOLEAN:
            choices = (
                (None, '---------'),
                (1, 'True'),
                (0, 'False'),
            )
            if initial is not None and initial.lower() in ['true', 'yes', '1']:
                initial = 1
            elif initial is not None and initial.lower() in ['false', 'no', '0']:
                initial = 0
            else:
                initial = None
            field = forms.NullBooleanField(
                required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
            )

        # Date
        elif cf.type == CF_TYPE_DATE:
            field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())

        # Select
        elif cf.type == CF_TYPE_SELECT:
            choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
            if not cf.required or bulk_edit or filterable_only:
                choices = [(None, '---------')] + choices
            # Check for a default choice
            default_choice = None
            if initial:
                try:
                    default_choice = cf.choices.get(value=initial).pk
                except ObjectDoesNotExist:
                    past
            field = forms.TypedChoiceField(
                choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
            )

        # URL
        elif cf.type == CF_TYPE_URL:
            field = LaxURLField(required=cf.required, initial=initial)

        # Text
        else:
            field = forms.CharField(max_length=255, required=cf.required, initial=initial)

        field.model = cf
        field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
        if cf.description:
            field.help_text = cf.description

        field_dict[field_name] = field

    return field_dict


clast CustomFieldForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):

        self.custom_fields = []
        self.obj_type = ContentType.objects.get_for_model(self._meta.model)

        super().__init__(*args, **kwargs)

        # Add all applicable CustomFields to the form
        custom_fields = []
        for name, field in get_custom_fields_for_model(self.obj_type).items():
            self.fields[name] = field
            custom_fields.append(name)
        self.custom_fields = custom_fields

        # If editing an existing object, initialize values for all custom fields
        if self.instance.pk:
            existing_values = CustomFieldValue.objects.filter(
                obj_type=self.obj_type,
                obj_id=self.instance.pk
            ).prefetch_related('field')
            for cfv in existing_values:
                self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value

    def _save_custom_fields(self):

        for field_name in self.custom_fields:
            try:
                cfv = CustomFieldValue.objects.prefetch_related('field').get(
                    field=self.fields[field_name].model,
                    obj_type=self.obj_type,
                    obj_id=self.instance.pk
                )
            except CustomFieldValue.DoesNotExist:
                # Skip this field if none exists already and its value is empty
                if self.cleaned_data[field_name] in [None, '']:
                    continue
                cfv = CustomFieldValue(
                    field=self.fields[field_name].model,
                    obj_type=self.obj_type,
                    obj_id=self.instance.pk
                )
            cfv.value = self.cleaned_data[field_name]
            cfv.save()

    def save(self, commit=True):
        obj = super().save(commit)

        # Handle custom fields the same way we do M2M fields
        if commit:
            self._save_custom_fields()
        else:
            self.save_custom_fields = self._save_custom_fields

        return obj


clast CustomFieldBulkEditForm(BulkEditForm):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.custom_fields = []
        self.obj_type = ContentType.objects.get_for_model(self.model)

        # Add all applicable CustomFields to the form
        custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
        for name, field in custom_fields:
            # Annotate non-required custom fields as nullable
            if not field.required:
                self.nullable_fields.append(name)
            field.required = False
            self.fields[name] = field
            # Annotate this as a custom field
            self.custom_fields.append(name)


clast CustomFieldFilterForm(forms.Form):

    def __init__(self, *args, **kwargs):

        self.obj_type = ContentType.objects.get_for_model(self.model)

        super().__init__(*args, **kwargs)

        # Add all applicable CustomFields to the form
        custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
        for name, field in custom_fields:
            field.required = False
            self.fields[name] = field


#
# Tags
#

clast TagForm(BootstrapMixin, forms.ModelForm):
    slug = SlugField()
    comments = CommentField()

    clast Meta:
        model = Tag
        fields = [
            'name', 'slug', 'color', 'comments'
        ]


clast AddRemoveTagsForm(forms.Form):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Add add/remove tags fields
        self.fields['add_tags'] = TagField(required=False)
        self.fields['remove_tags'] = TagField(required=False)


clast TagFilterForm(BootstrapMixin, forms.Form):
    model = Tag
    q = forms.CharField(
        required=False,
        label='Search'
    )


clast TagBulkEditForm(BootstrapMixin, BulkEditForm):
    pk = forms.ModelMultipleChoiceField(
        queryset=Tag.objects.all(),
        widget=forms.MultipleHiddenInput
    )
    color = forms.CharField(
        max_length=6,
        required=False,
        widget=ColorSelect()
    )

    clast Meta:
        nullable_fields = []


#
# Config contexts
#

clast ConfigContextForm(BootstrapMixin, forms.ModelForm):
    data = JSONField(
        label=''
    )

    clast Meta:
        model = ConfigContext
        fields = [
            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
            'tenants', 'data',
        ]
        widgets = {
            'regions': APISelectMultiple(
                api_url="/api/dcim/regions/"
            ),
            'sites': APISelectMultiple(
                api_url="/api/dcim/sites/"
            ),
            'roles': APISelectMultiple(
                api_url="/api/dcim/device-roles/"
            ),
            'platforms': APISelectMultiple(
                api_url="/api/dcim/platforms/"
            ),
            'tenant_groups': APISelectMultiple(
                api_url="/api/tenancy/tenant-groups/"
            ),
            'tenants': APISelectMultiple(
                api_url="/api/tenancy/tenants/"
            )
        }


clast ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
    pk = forms.ModelMultipleChoiceField(
        queryset=ConfigContext.objects.all(),
        widget=forms.MultipleHiddenInput
    )
    weight = forms.IntegerField(
        required=False,
        min_value=0
    )
    is_active = forms.NullBooleanField(
        required=False,
        widget=BulkEditNullBooleanSelect()
    )
    description = forms.CharField(
        required=False,
        max_length=100
    )

    clast Meta:
        nullable_fields = [
            'description',
        ]


clast ConfigContextFilterForm(BootstrapMixin, forms.Form):
    q = forms.CharField(
        required=False,
        label='Search'
    )
    region = FilterChoiceField(
        queryset=Region.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/dcim/regions/",
            value_field="slug",
        )
    )
    site = FilterChoiceField(
        queryset=Site.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/dcim/sites/",
            value_field="slug",
        )
    )
    role = FilterChoiceField(
        queryset=DeviceRole.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/dcim/device-roles/",
            value_field="slug",
        )
    )
    platform = FilterChoiceField(
        queryset=Platform.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/dcim/platforms/",
            value_field="slug",
        )
    )
    tenant_group = FilterChoiceField(
        queryset=TenantGroup.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/tenancy/tenant-groups/",
            value_field="slug",
        )
    )
    tenant = FilterChoiceField(
        queryset=Tenant.objects.all(),
        to_field_name='slug',
        widget=APISelectMultiple(
            api_url="/api/tenancy/tenants/",
            value_field="slug",
        )
    )


#
# Filter form for local config context data
#

clast LocalConfigContextFilterForm(forms.Form):
    local_context_data = forms.NullBooleanField(
        required=False,
        label='Has local config context data',
        widget=StaticSelect2(
            choices=BOOLEAN_WITH_BLANK_CHOICES
        )
    )


#
# Image attachments
#

clast ImageAttachmentForm(BootstrapMixin, forms.ModelForm):

    clast Meta:
        model = ImageAttachment
        fields = [
            'name', 'image',
        ]


#
# Change logging
#

clast ObjectChangeFilterForm(BootstrapMixin, forms.Form):
    model = ObjectChange
    q = forms.CharField(
        required=False,
        label='Search'
    )
    time_after = forms.DateTimeField(
        label='After',
        required=False,
        widget=DateTimePicker()
    )
    time_before = forms.DateTimeField(
        label='Before',
        required=False,
        widget=DateTimePicker()
    )
    action = forms.ChoiceField(
        choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
        required=False
    )
    user = forms.ModelChoiceField(
        queryset=User.objects.order_by('username'),
        required=False
    )
    changed_object_type = forms.ModelChoiceField(
        queryset=ContentType.objects.order_by('model'),
        required=False,
        widget=ContentTypeSelect(),
        label='Object Type'
    )


#
# Scripts
#

clast ScriptForm(BootstrapMixin, forms.Form):
    _commit = forms.BooleanField(
        required=False,
        initial=True,
        label="Commit changes",
        help_text="Commit changes to the database (uncheck for a dry-run)"
    )

    def __init__(self, vars, *args, commit_default=True, **kwargs):

        super().__init__(*args, **kwargs)

        # Dynamically populate fields for variables
        for name, var in vars.items():
            self.fields[name] = var.as_field()

        # Toggle default commit behavior based on Meta option
        if not commit_default:
            self.fields['_commit'].initial = False

        # Move _commit to the end of the form
        self.fields.move_to_end('_commit', True)

    @property
    def requires_input(self):
        """
        A boolean indicating whether the form requires user input (ignore the _commit field).
        """
        return bool(len(self.fields) > 1)