diff --git a/netbox/extras/migrations/0006_add_imageattachments.py b/netbox/extras/migrations/0006_add_imageattachments.py index 6842cced0..b25327c33 100644 --- a/netbox/extras/migrations/0006_add_imageattachments.py +++ b/netbox/extras/migrations/0006_add_imageattachments.py @@ -2,7 +2,7 @@ # Generated by Django 1.11 on 2017-04-04 19:58 from django.db import migrations, models import django.db.models.deletion -import extras.models +import extras.utils class Migration(migrations.Migration): @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('object_id', models.PositiveIntegerField()), - ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')), + ('image', models.ImageField(height_field=b'image_height', upload_to=extras.utils.image_upload, width_field=b'image_width')), ('image_height', models.PositiveSmallIntegerField()), ('image_width', models.PositiveSmallIntegerField()), ('name', models.CharField(blank=True, max_length=50)), diff --git a/netbox/extras/migrations/0007_unicode_literals.py b/netbox/extras/migrations/0007_unicode_literals.py index fecb33b7b..88525a24a 100644 --- a/netbox/extras/migrations/0007_unicode_literals.py +++ b/netbox/extras/migrations/0007_unicode_literals.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 from django.db import migrations, models -import extras.models +import extras.utils class Migration(migrations.Migration): @@ -74,7 +74,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='imageattachment', name='image', - field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'), + field=models.ImageField(height_field='image_height', upload_to=extras.utils.image_upload, width_field='image_width'), ), migrations.AlterField( model_name='topologymap', diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py new file mode 100644 index 000000000..2942bfa48 --- /dev/null +++ b/netbox/extras/models/__init__.py @@ -0,0 +1,25 @@ +from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue +from .models import ( + ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, + Script, Webhook, +) +from .tags import Tag, TaggedItem + +__all__ = ( + 'ConfigContext', + 'ConfigContextModel', + 'CustomField', + 'CustomFieldChoice', + 'CustomFieldModel', + 'CustomFieldValue', + 'CustomLink', + 'ExportTemplate', + 'Graph', + 'ImageAttachment', + 'ObjectChange', + 'ReportResult', + 'Script', + 'Tag', + 'TaggedItem', + 'Webhook', +) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py new file mode 100644 index 000000000..47bccd98a --- /dev/null +++ b/netbox/extras/models/customfields.py @@ -0,0 +1,297 @@ +from collections import OrderedDict +from datetime import date + +from django import forms +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.validators import ValidationError +from django.db import models + +from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from extras.choices import * +from extras.utils import FeatureQuery + + +# +# Custom fields +# + +class CustomFieldModel(models.Model): + _cf = None + + class Meta: + abstract = True + + def cache_custom_fields(self): + """ + Cache all custom field values for this instance + """ + self._cf = { + field.name: value for field, value in self.get_custom_fields().items() + } + + @property + def cf(self): + """ + Name-based CustomFieldValue accessor for use in templates + """ + if self._cf is None: + self.cache_custom_fields() + return self._cf + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ + + # Find all custom fields applicable to this type of object + content_type = ContentType.objects.get_for_model(self) + fields = CustomField.objects.filter(obj_type=content_type) + + # If the object exists, populate its custom fields with values + if hasattr(self, 'pk'): + values = self.custom_field_values.all() + values_dict = {cfv.field_id: cfv.value for cfv in values} + return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) + else: + return OrderedDict([(field, None) for field in fields]) + + +class CustomField(models.Model): + obj_type = models.ManyToManyField( + to=ContentType, + related_name='custom_fields', + verbose_name='Object(s)', + limit_choices_to=FeatureQuery('custom_fields'), + help_text='The object(s) to which this field applies.' + ) + type = models.CharField( + max_length=50, + choices=CustomFieldTypeChoices, + default=CustomFieldTypeChoices.TYPE_TEXT + ) + name = models.CharField( + max_length=50, + unique=True + ) + label = models.CharField( + 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)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + required = models.BooleanField( + default=False, + help_text='If true, this field is required when creating new objects ' + 'or editing an existing object.' + ) + filter_logic = models.CharField( + 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.CharField( + max_length=100, + blank=True, + help_text='Default value for the field. Use "true" or "false" for booleans.' + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Fields with higher weights appear lower in a form.' + ) + + class Meta: + ordering = ['weight', 'name'] + + def __str__(self): + return self.label or self.name.replace('_', ' ').capitalize() + + def serialize_value(self, value): + """ + Serialize the given value to a string suitable for storage as a CustomFieldValue + """ + if value is None: + return '' + if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + return str(int(bool(value))) + if self.type == CustomFieldTypeChoices.TYPE_DATE: + # Could be date/datetime object or string + try: + return value.strftime('%Y-%m-%d') + except AttributeError: + return value + if self.type == CustomFieldTypeChoices.TYPE_SELECT: + # Could be ModelChoiceField or TypedChoiceField + return str(value.id) if hasattr(value, 'id') else str(value) + return value + + def deserialize_value(self, serialized_value): + """ + Convert a string into the object it represents depending on the type of field + """ + if serialized_value == '': + return None + if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + return int(serialized_value) + if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + return bool(int(serialized_value)) + if self.type == CustomFieldTypeChoices.TYPE_DATE: + # Read date as YYYY-MM-DD + return date(*[int(n) for n in serialized_value.split('-')]) + if self.type == CustomFieldTypeChoices.TYPE_SELECT: + return self.choices.get(pk=int(serialized_value)) + return serialized_value + + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): + """ + Return a form field suitable for setting a CustomField's value for an object. + + set_initial: Set initial date 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. + 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) + + # Boolean + elif self.type == CustomFieldTypeChoices.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=required, initial=initial, widget=StaticSelect2(choices=choices) + ) + + # Date + elif self.type == CustomFieldTypeChoices.TYPE_DATE: + field = forms.DateField(required=required, initial=initial, widget=DatePicker()) + + # Select + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] + + if not required: + choices = add_blank_choice(choices) + + # Set the initial value to the PK of the default choice, if any + if set_initial: + default_choice = self.choices.filter(value=self.default).first() + if default_choice: + initial = default_choice.pk + + field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2() + ) + + # URL + elif self.type == CustomFieldTypeChoices.TYPE_URL: + field = LaxURLField(required=required, initial=initial) + + # Text + else: + field = forms.CharField(max_length=255, required=required, initial=initial) + + field.model = self + field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() + if self.description: + field.help_text = self.description + + return field + + +class CustomFieldValue(models.Model): + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='values' + ) + obj_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+' + ) + obj_id = models.PositiveIntegerField() + obj = GenericForeignKey( + ct_field='obj_type', + fk_field='obj_id' + ) + serialized_value = models.CharField( + max_length=255 + ) + + class Meta: + ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique + unique_together = ('field', 'obj_type', 'obj_id') + + def __str__(self): + return '{} {}'.format(self.obj, self.field) + + @property + def value(self): + return self.field.deserialize_value(self.serialized_value) + + @value.setter + def value(self, value): + self.serialized_value = self.field.serialize_value(value) + + def save(self, *args, **kwargs): + # Delete this object if it no longer has a value to store + if self.pk and self.value is None: + self.delete() + else: + super().save(*args, **kwargs) + + +class CustomFieldChoice(models.Model): + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='choices', + limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT} + ) + value = models.CharField( + max_length=100 + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Higher weights appear lower in the list' + ) + + class Meta: + ordering = ['field', 'weight', 'value'] + unique_together = ['field', 'value'] + + def __str__(self): + return self.value + + def clean(self): + if self.field.type != CustomFieldTypeChoices.TYPE_SELECT: + raise ValidationError("Custom field choices can only be assigned to selection fields.") + + def delete(self, using=None, keep_parents=False): + # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it + pk = self.pk + super().delete(using, keep_parents) + CustomFieldValue.objects.filter( + field__type=CustomFieldTypeChoices.TYPE_SELECT, + serialized_value=str(pk) + ).delete() diff --git a/netbox/extras/models.py b/netbox/extras/models/models.py similarity index 63% rename from netbox/extras/models.py rename to netbox/extras/models/models.py index 488554596..f98a7b34f 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models/models.py @@ -1,8 +1,6 @@ import json from collections import OrderedDict -from datetime import date -from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -12,37 +10,13 @@ from django.db import models from django.http import HttpResponse from django.template import Template, Context from django.urls import reverse -from django.utils.text import slugify from rest_framework.utils.encoders import JSONEncoder -from taggit.models import TagBase, GenericTaggedItemBase -from utilities.fields import ColorField -from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.utils import deepmerge, render_jinja2 -from .choices import * -from .constants import * -from .querysets import ConfigContextQuerySet -from .utils import FeatureQuery - - -__all__ = ( - 'ConfigContext', - 'ConfigContextModel', - 'CustomField', - 'CustomFieldChoice', - 'CustomFieldModel', - 'CustomFieldValue', - 'CustomLink', - 'ExportTemplate', - 'Graph', - 'ImageAttachment', - 'ObjectChange', - 'ReportResult', - 'Script', - 'Tag', - 'TaggedItem', - 'Webhook', -) +from extras.choices import * +from extras.constants import * +from extras.querysets import ConfigContextQuerySet +from extras.utils import FeatureQuery, image_upload # @@ -174,291 +148,6 @@ class Webhook(models.Model): return json.dumps(context, cls=JSONEncoder) -# -# Custom fields -# - -class CustomFieldModel(models.Model): - _cf = None - - class Meta: - abstract = True - - def cache_custom_fields(self): - """ - Cache all custom field values for this instance - """ - self._cf = { - field.name: value for field, value in self.get_custom_fields().items() - } - - @property - def cf(self): - """ - Name-based CustomFieldValue accessor for use in templates - """ - if self._cf is None: - self.cache_custom_fields() - return self._cf - - def get_custom_fields(self): - """ - Return a dictionary of custom fields for a single object in the form {: value}. - """ - - # Find all custom fields applicable to this type of object - content_type = ContentType.objects.get_for_model(self) - fields = CustomField.objects.filter(obj_type=content_type) - - # If the object exists, populate its custom fields with values - if hasattr(self, 'pk'): - values = self.custom_field_values.all() - values_dict = {cfv.field_id: cfv.value for cfv in values} - return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) - else: - return OrderedDict([(field, None) for field in fields]) - - -class CustomField(models.Model): - obj_type = models.ManyToManyField( - to=ContentType, - related_name='custom_fields', - verbose_name='Object(s)', - limit_choices_to=FeatureQuery('custom_fields'), - help_text='The object(s) to which this field applies.' - ) - type = models.CharField( - max_length=50, - choices=CustomFieldTypeChoices, - default=CustomFieldTypeChoices.TYPE_TEXT - ) - name = models.CharField( - max_length=50, - unique=True - ) - label = models.CharField( - 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)' - ) - description = models.CharField( - max_length=200, - blank=True - ) - required = models.BooleanField( - default=False, - help_text='If true, this field is required when creating new objects ' - 'or editing an existing object.' - ) - filter_logic = models.CharField( - 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.CharField( - max_length=100, - blank=True, - help_text='Default value for the field. Use "true" or "false" for booleans.' - ) - weight = models.PositiveSmallIntegerField( - default=100, - help_text='Fields with higher weights appear lower in a form.' - ) - - class Meta: - ordering = ['weight', 'name'] - - def __str__(self): - return self.label or self.name.replace('_', ' ').capitalize() - - def serialize_value(self, value): - """ - Serialize the given value to a string suitable for storage as a CustomFieldValue - """ - if value is None: - return '' - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - return str(int(bool(value))) - if self.type == CustomFieldTypeChoices.TYPE_DATE: - # Could be date/datetime object or string - try: - return value.strftime('%Y-%m-%d') - except AttributeError: - return value - if self.type == CustomFieldTypeChoices.TYPE_SELECT: - # Could be ModelChoiceField or TypedChoiceField - return str(value.id) if hasattr(value, 'id') else str(value) - return value - - def deserialize_value(self, serialized_value): - """ - Convert a string into the object it represents depending on the type of field - """ - if serialized_value == '': - return None - if self.type == CustomFieldTypeChoices.TYPE_INTEGER: - return int(serialized_value) - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - return bool(int(serialized_value)) - if self.type == CustomFieldTypeChoices.TYPE_DATE: - # Read date as YYYY-MM-DD - return date(*[int(n) for n in serialized_value.split('-')]) - if self.type == CustomFieldTypeChoices.TYPE_SELECT: - return self.choices.get(pk=int(serialized_value)) - return serialized_value - - def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): - """ - Return a form field suitable for setting a CustomField's value for an object. - - set_initial: Set initial date 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. - 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) - - # Boolean - elif self.type == CustomFieldTypeChoices.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=required, initial=initial, widget=StaticSelect2(choices=choices) - ) - - # Date - elif self.type == CustomFieldTypeChoices.TYPE_DATE: - field = forms.DateField(required=required, initial=initial, widget=DatePicker()) - - # Select - elif self.type == CustomFieldTypeChoices.TYPE_SELECT: - choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] - - if not required: - choices = add_blank_choice(choices) - - # Set the initial value to the PK of the default choice, if any - if set_initial: - default_choice = self.choices.filter(value=self.default).first() - if default_choice: - initial = default_choice.pk - - field_class = CSVChoiceField if for_csv_import else forms.ChoiceField - field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect2() - ) - - # URL - elif self.type == CustomFieldTypeChoices.TYPE_URL: - field = LaxURLField(required=required, initial=initial) - - # Text - else: - field = forms.CharField(max_length=255, required=required, initial=initial) - - field.model = self - field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() - if self.description: - field.help_text = self.description - - return field - - -class CustomFieldValue(models.Model): - field = models.ForeignKey( - to='extras.CustomField', - on_delete=models.CASCADE, - related_name='values' - ) - obj_type = models.ForeignKey( - to=ContentType, - on_delete=models.PROTECT, - related_name='+' - ) - obj_id = models.PositiveIntegerField() - obj = GenericForeignKey( - ct_field='obj_type', - fk_field='obj_id' - ) - serialized_value = models.CharField( - max_length=255 - ) - - class Meta: - ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique - unique_together = ('field', 'obj_type', 'obj_id') - - def __str__(self): - return '{} {}'.format(self.obj, self.field) - - @property - def value(self): - return self.field.deserialize_value(self.serialized_value) - - @value.setter - def value(self, value): - self.serialized_value = self.field.serialize_value(value) - - def save(self, *args, **kwargs): - # Delete this object if it no longer has a value to store - if self.pk and self.value is None: - self.delete() - else: - super().save(*args, **kwargs) - - -class CustomFieldChoice(models.Model): - field = models.ForeignKey( - to='extras.CustomField', - on_delete=models.CASCADE, - related_name='choices', - limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT} - ) - value = models.CharField( - max_length=100 - ) - weight = models.PositiveSmallIntegerField( - default=100, - help_text='Higher weights appear lower in the list' - ) - - class Meta: - ordering = ['field', 'weight', 'value'] - unique_together = ['field', 'value'] - - def __str__(self): - return self.value - - def clean(self): - if self.field.type != CustomFieldTypeChoices.TYPE_SELECT: - raise ValidationError("Custom field choices can only be assigned to selection fields.") - - def delete(self, using=None, keep_parents=False): - # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it - pk = self.pk - super().delete(using, keep_parents) - CustomFieldValue.objects.filter( - field__type=CustomFieldTypeChoices.TYPE_SELECT, - serialized_value=str(pk) - ).delete() - - # # Custom links # @@ -663,20 +352,6 @@ class ExportTemplate(models.Model): # Image attachments # -def image_upload(instance, filename): - - path = 'image-attachments/' - - # Rename the file to the provided name, if any. Attempt to preserve the file extension. - extension = filename.rsplit('.')[-1].lower() - if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: - filename = '.'.join([instance.name, extension]) - elif instance.name: - filename = instance.name - - return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) - - class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. @@ -1038,44 +713,3 @@ class ObjectChange(models.Model): self.object_repr, self.object_data, ) - - -# -# Tags -# - -# TODO: figure out a way around this circular import for ObjectChange -from utilities.models import ChangeLoggedModel # noqa: E402 - - -class Tag(TagBase, ChangeLoggedModel): - color = ColorField( - default='9e9e9e' - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - def get_absolute_url(self): - return reverse('extras:tag', args=[self.slug]) - - def slugify(self, tag, i=None): - # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) - slug = slugify(tag, allow_unicode=True) - if i is not None: - slug += "_%d" % i - return slug - - -class TaggedItem(GenericTaggedItemBase): - tag = models.ForeignKey( - to=Tag, - related_name="%(app_label)s_%(class)s_items", - on_delete=models.CASCADE - ) - - class Meta: - index_together = ( - ("content_type", "object_id") - ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py new file mode 100644 index 000000000..3bad7fa8b --- /dev/null +++ b/netbox/extras/models/tags.py @@ -0,0 +1,44 @@ +from django.db import models +from django.urls import reverse +from django.utils.text import slugify +from taggit.models import TagBase, GenericTaggedItemBase + +from utilities.fields import ColorField +from utilities.models import ChangeLoggedModel + + +# +# Tags +# + +class Tag(TagBase, ChangeLoggedModel): + color = ColorField( + default='9e9e9e' + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + def get_absolute_url(self): + return reverse('extras:tag', args=[self.slug]) + + def slugify(self, tag, i=None): + # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) + slug = slugify(tag, allow_unicode=True) + if i is not None: + slug += "_%d" % i + return slug + + +class TaggedItem(GenericTaggedItemBase): + tag = models.ForeignKey( + to=Tag, + related_name="%(app_label)s_%(class)s_items", + on_delete=models.CASCADE + ) + + class Meta: + index_together = ( + ("content_type", "object_id") + ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 78214fe41..edbd509f1 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -22,6 +22,22 @@ def is_taggable(obj): return False +def image_upload(instance, filename): + """ + Return a path for uploading image attchments. + """ + path = 'image-attachments/' + + # Rename the file to the provided name, if any. Attempt to preserve the file extension. + extension = filename.rsplit('.')[-1].lower() + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + filename = '.'.join([instance.name, extension]) + elif instance.name: + filename = instance.name + + return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) + + @deconstructible class FeatureQuery: """