Jelajahi Sumber

Merge pull request #4608 from netbox-community/3226-customfield-manager

Closes #3226: Implement a custom manager for CustomField
Jeremy Stretch 5 tahun lalu
induk
melakukan
d5b9722533

+ 2 - 2
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)),

+ 2 - 2
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',

+ 20 - 0
netbox/extras/migrations/0042_customfield_manager.py

@@ -0,0 +1,20 @@
+# Generated by Django 3.0.5 on 2020-05-07 21:06
+
+from django.db import migrations
+import extras.models.customfields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0041_tag_description'),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name='customfield',
+            managers=[
+                ('objects', extras.models.customfields.CustomFieldManager()),
+            ],
+        ),
+    ]

+ 25 - 0
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',
+)

+ 308 - 0
netbox/extras/models/customfields.py

@@ -0,0 +1,308 @@
+import logging
+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 {<field>: value}.
+        """
+        fields = CustomField.objects.get_for_model(self)
+
+        # 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 CustomFieldManager(models.Manager):
+    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(obj_type=content_type)
+
+
+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.'
+    )
+
+    objects = CustomFieldManager()
+
+    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()

+ 4 - 370
netbox/extras/models.py → 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 {<field>: 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")
-        )

+ 44 - 0
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")
+        )

+ 13 - 0
netbox/extras/tests/test_customfields.py

@@ -99,6 +99,19 @@ class CustomFieldTest(TestCase):
         cf.delete()
 
 
+class CustomFieldManagerTest(TestCase):
+
+    def setUp(self):
+        content_type = ContentType.objects.get_for_model(Site)
+        custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
+        custom_field.save()
+        custom_field.obj_type.set([content_type])
+
+    def test_get_for_model(self):
+        self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
+        self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
+
+
 class CustomFieldAPITest(APITestCase):
 
     @classmethod

+ 16 - 0
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:
     """