Browse Source

Merge pull request #1026 from digitalocean/image-attachments

#152: Image attachments
Jeremy Stretch 8 years ago
parent
commit
2cde9a82a0

+ 4 - 1
netbox/dcim/models.py

@@ -15,7 +15,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from extras.models import CustomFieldModel, CustomField, CustomFieldValue
+from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.fields import ColorField, NullableCharField
 from utilities.fields import ColorField, NullableCharField
@@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = SiteManager()
     objects = SiteManager()
 
 
@@ -375,6 +376,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                                      help_text='Units are numbered top-to-bottom')
                                      help_text='Units are numbered top-to-bottom')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = RackManager()
     objects = RackManager()
 
 
@@ -932,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
                                        blank=True, null=True, verbose_name='Primary IPv6')
                                        blank=True, null=True, verbose_name='Primary IPv6')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = DeviceManager()
     objects = DeviceManager()
 
 

+ 5 - 0
netbox/dcim/urls.py

@@ -3,6 +3,8 @@ from django.conf.urls import url
 from ipam.views import ServiceEditView
 from ipam.views import ServiceEditView
 from secrets.views import secret_add
 from secrets.views import secret_add
 
 
+from extras.views import ImageAttachmentEditView
+from .models import Device, Rack, Site
 from . import views
 from . import views
 
 
 
 
@@ -22,6 +24,7 @@ urlpatterns = [
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
+    url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
     # Rack groups
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
@@ -49,6 +52,7 @@ urlpatterns = [
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
+    url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
     # Manufacturers
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -117,6 +121,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
+    url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
 
     # Console ports
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),

+ 54 - 3
netbox/extras/api/serializers.py

@@ -1,9 +1,14 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from dcim.api.serializers import NestedSiteSerializer
-from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction
+from django.core.exceptions import ObjectDoesNotExist
+
+from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
+from dcim.models import Device, Rack, Site
+from extras.models import (
+    ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
+)
 from users.api.serializers import NestedUserSerializer
 from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer
+from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer
 
 
 
 
 #
 #
@@ -72,6 +77,52 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer):
 
 
 
 
 #
 #
+# Image attachments
+#
+
+class ImageAttachmentSerializer(serializers.ModelSerializer):
+    parent = serializers.SerializerMethodField()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
+
+    def get_parent(self, obj):
+
+        # Static mapping of models to their nested serializers
+        if isinstance(obj.parent, Device):
+            serializer = NestedDeviceSerializer
+        elif isinstance(obj.parent, Rack):
+            serializer = NestedRackSerializer
+        elif isinstance(obj.parent, Site):
+            serializer = NestedSiteSerializer
+        else:
+            raise Exception("Unexpected type of parent object for ImageAttachment")
+
+        return serializer(obj.parent, context={'request': self.context['request']}).data
+
+
+class WritableImageAttachmentSerializer(serializers.ModelSerializer):
+    content_type = ContentTypeFieldSerializer()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type', 'object_id', 'name', 'image']
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        try:
+            data['content_type'].get_object_for_this_type(id=data['object_id'])
+        except ObjectDoesNotExist:
+            raise serializers.ValidationError(
+                "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
+            )
+
+        return data
+
+
+#
 # User actions
 # User actions
 #
 #
 
 

+ 3 - 0
netbox/extras/api/urls.py

@@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
 # Topology maps
 # Topology maps
 router.register(r'topology-maps', views.TopologyMapViewSet)
 router.register(r'topology-maps', views.TopologyMapViewSet)
 
 
+# Image attachments
+router.register(r'image-attachments', views.ImageAttachmentViewSet)
+
 # Recent activity
 # Recent activity
 router.register(r'recent-activity', views.RecentActivityViewSet)
 router.register(r'recent-activity', views.RecentActivityViewSet)
 
 

+ 7 - 1
netbox/extras/api/views.py

@@ -6,7 +6,7 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 
 
 from extras import filters
 from extras import filters
-from extras.models import ExportTemplate, Graph, TopologyMap, UserAction
+from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
 from utilities.api import WritableSerializerMixin
 from utilities.api import WritableSerializerMixin
 from . import serializers
 from . import serializers
 
 
@@ -80,6 +80,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
         return response
         return response
 
 
 
 
+class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ImageAttachment.objects.all()
+    serializer_class = serializers.ImageAttachmentSerializer
+    write_serializer_class = serializers.WritableImageAttachmentSerializer
+
+
 class RecentActivityViewSet(ReadOnlyModelViewSet):
 class RecentActivityViewSet(ReadOnlyModelViewSet):
     """
     """
     List all UserActions to provide a log of recent activity.
     List all UserActions to provide a log of recent activity.

+ 10 - 2
netbox/extras/forms.py

@@ -3,9 +3,10 @@ from collections import OrderedDict
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
-from utilities.forms import BulkEditForm, LaxURLField
+from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from .models import (
 from .models import (
-    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
+    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue,
+    ImageAttachment,
 )
 )
 
 
 
 
@@ -158,3 +159,10 @@ class CustomFieldFilterForm(forms.Form):
         for name, field in custom_fields:
         for name, field in custom_fields:
             field.required = False
             field.required = False
             self.fields[name] = field
             self.fields[name] = field
+
+
+class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['name', 'image']

+ 34 - 0
netbox/extras/migrations/0005_add_imageattachment.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.6 on 2017-04-03 15:55
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0004_topologymap_change_comma_to_semicolon'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ImageAttachment',
+            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_height', models.PositiveSmallIntegerField()),
+                ('image_width', models.PositiveSmallIntegerField()),
+                ('name', models.CharField(blank=True, max_length=50)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+    ]

+ 55 - 0
netbox/extras/models.py

@@ -360,6 +360,61 @@ class TopologyMap(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]
+    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)
+
+
+@python_2_unicode_compatible
+class ImageAttachment(models.Model):
+    """
+    An uploaded image which is associated with an object.
+    """
+    content_type = models.ForeignKey(ContentType)
+    object_id = models.PositiveIntegerField()
+    parent = GenericForeignKey('content_type', 'object_id')
+    image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
+    image_height = models.PositiveSmallIntegerField()
+    image_width = models.PositiveSmallIntegerField()
+    name = models.CharField(max_length=50, blank=True)
+    created = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        if self.name:
+            return self.name
+        filename = self.image.name.rsplit('/', 1)[-1]
+        return filename.split('_', 2)[2]
+
+    def delete(self, *args, **kwargs):
+
+        _name = self.image.name
+
+        super(ImageAttachment, self).delete(*args, **kwargs)
+
+        # Delete file from disk
+        self.image.delete(save=False)
+
+        # Deleting the file erases its name. We restore the image's filename here in case we still need to reference it
+        # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
+        self.image.name = _name
+
+
+#
 # User actions
 # User actions
 #
 #
 
 

+ 12 - 0
netbox/extras/urls.py

@@ -0,0 +1,12 @@
+from django.conf.urls import url
+
+from extras import views
+
+
+urlpatterns = [
+
+    # Image attachments
+    url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+
+]

+ 30 - 0
netbox/extras/views.py

@@ -0,0 +1,30 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.shortcuts import get_object_or_404
+
+from utilities.views import ObjectDeleteView, ObjectEditView
+from .forms import ImageAttachmentForm
+from .models import ImageAttachment
+
+
+class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'extras.change_imageattachment'
+    model = ImageAttachment
+    form_class = ImageAttachmentForm
+
+    def alter_obj(self, imageattachment, request, args, kwargs):
+        if not imageattachment.pk:
+            # Assign the parent object based on URL kwargs
+            model = kwargs.get('model')
+            imageattachment.obj = get_object_or_404(model, pk=kwargs['object_id'])
+        return imageattachment
+
+    def get_return_url(self, imageattachment):
+        return imageattachment.obj.get_absolute_url()
+
+
+class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_imageattachment'
+    model = ImageAttachment
+
+    def get_return_url(self, imageattachment):
+        return imageattachment.obj.get_absolute_url()

+ 2 - 0
netbox/media/image-attachments/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 5 - 2
netbox/netbox/settings.py

@@ -153,6 +153,7 @@ TEMPLATES = [
             'context_processors': [
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
                 'django.template.context_processors.request',
+                'django.template.context_processors.media',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'django.contrib.messages.context_processors.messages',
                 'utilities.context_processors.settings',
                 'utilities.context_processors.settings',
@@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 USE_X_FORWARDED_HOST = True
 USE_X_FORWARDED_HOST = True
 
 
 # Internationalization
 # Internationalization
-# https://docs.djangoproject.com/en/1.8/topics/i18n/
 LANGUAGE_CODE = 'en-us'
 LANGUAGE_CODE = 'en-us'
 USE_I18N = True
 USE_I18N = True
 USE_TZ = True
 USE_TZ = True
 
 
 # Static files (CSS, JavaScript, Images)
 # Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/1.8/howto/static-files/
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATICFILES_DIRS = (
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
     os.path.join(BASE_DIR, "project-static"),
 )
 )
 
 
+# Media
+MEDIA_URL = '/media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+
 # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
 # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
 
 

+ 5 - 0
netbox/netbox/urls.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf import settings
 from django.conf.urls import include, url
 from django.conf.urls import include, url
 from django.contrib import admin
 from django.contrib import admin
+from django.views.static import serve
 
 
 from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
 from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
 from users.views import login, logout
 from users.views import login, logout
@@ -21,6 +22,7 @@ _patterns = [
     # Apps
     # Apps
     url(r'^circuits/', include('circuits.urls', namespace='circuits')),
     url(r'^circuits/', include('circuits.urls', namespace='circuits')),
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
+    url(r'^extras/', include('extras.urls', namespace='extras')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
     url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
@@ -36,6 +38,9 @@ _patterns = [
     url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
 
 
+    # Serving static media in Django to pipe it through LoginRequiredMiddleware
+    url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
+
     # Error testing
     # Error testing
     url(r'^500/$', trigger_500),
     url(r'^500/$', trigger_500),
 
 

+ 14 - 0
netbox/templates/dcim/device.html

@@ -328,6 +328,20 @@
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
+                <strong>Images</strong>
+            </div>
+            {% include 'inc/image_attachments.html' with images=device.images.all %}
+            {% if perms.extras.add_imageattachment %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:device_add_image' object_id=device.pk %}" class="btn btn-primary btn-xs">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                        Attach an image
+                    </a>
+                </div>
+            {% endif %}
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
                 <strong>Related Devices</strong>
                 <strong>Related Devices</strong>
             </div>
             </div>
             {% if related_devices %}
             {% if related_devices %}

+ 14 - 0
netbox/templates/dcim/rack.html

@@ -199,6 +199,20 @@
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
+                <strong>Images</strong>
+            </div>
+            {% include 'inc/image_attachments.html' with images=rack.images.all %}
+            {% if perms.extras.add_imageattachment %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:rack_add_image' object_id=rack.pk %}" class="btn btn-primary btn-xs">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                        Attach an image
+                    </a>
+                </div>
+            {% endif %}
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
                 <strong>Reservations</strong>
                 <strong>Reservations</strong>
             </div>
             </div>
             {% if reservations %}
             {% if reservations %}

+ 14 - 0
netbox/templates/dcim/site.html

@@ -237,6 +237,20 @@
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
+                <strong>Images</strong>
+            </div>
+            {% include 'inc/image_attachments.html' with images=site.images.all %}
+            {% if perms.extras.add_imageattachment %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:site_add_image' object_id=site.pk %}" class="btn btn-primary btn-xs">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                        Attach an image
+                    </a>
+                </div>
+            {% endif %}
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
                 <strong>Topology Maps</strong>
                 <strong>Topology Maps</strong>
             </div>
             </div>
             {% if topology_maps %}
             {% if topology_maps %}

+ 36 - 0
netbox/templates/inc/image_attachments.html

@@ -0,0 +1,36 @@
+{% if images %}
+    <table class="table table-hover panel-body">
+        <tr>
+            <th>Name</th>
+            <th>Size</th>
+            <th>Created</th>
+            <th></th>
+        </tr>
+        {% for attachment in images %}
+            <tr>
+                <td>
+                    <i class="fa fa-image"></i>
+                    <a href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
+                </td>
+                <td>{{ attachment.image.size|filesizeformat }}</td>
+                <td>{{ attachment.created }}</td>
+                <td class="text-right">
+                    {% if perms.extras.change_imageattachment %}
+                        <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-xs" title="Edit image">
+                            <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+                        </a>
+                    {% endif %}
+                    {% if perms.extras.delete_imageattachment %}
+                        <a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-xs" title="Delete image">
+                            <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+                        </a>
+                    {% endif %}
+                </td>
+            </tr>
+        {% endfor %}
+    </table>
+{% else %}
+    <div class="panel-body">
+        <span class="text-muted">None</span>
+    </div>
+{% endif %}

+ 1 - 1
netbox/templates/utilities/obj_edit.html

@@ -2,7 +2,7 @@
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
-    <form action="." method="post" class="form form-horizontal">
+    <form action="." method="post" enctype="multipart/form-data" class="form form-horizontal">
         {% csrf_token %}
         {% csrf_token %}
         {% for field in form.hidden_fields %}
         {% for field in form.hidden_fields %}
             {{ field }}
             {{ field }}

+ 17 - 1
netbox/utilities/api.py

@@ -1,9 +1,10 @@
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 
 
 from rest_framework import authentication, exceptions
 from rest_framework import authentication, exceptions
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
 from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
-from rest_framework.serializers import Field
+from rest_framework.serializers import Field, ValidationError
 
 
 from users.models import Token
 from users.models import Token
 
 
@@ -79,6 +80,21 @@ class ChoiceFieldSerializer(Field):
         return self._choices.get(data)
         return self._choices.get(data)
 
 
 
 
+class ContentTypeFieldSerializer(Field):
+    """
+    Represent a ContentType as '<app_label>.<model>'
+    """
+    def to_representation(self, obj):
+        return "{}.{}".format(obj.app_label, obj.model)
+
+    def to_internal_value(self, data):
+        app_label, model = data.split('.')
+        try:
+            return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
+        except ContentType.DoesNotExist:
+            raise ValidationError("Invalid content type")
+
+
 class WritableSerializerMixin(object):
 class WritableSerializerMixin(object):
     """
     """
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).

+ 6 - 5
netbox/utilities/forms.py

@@ -425,12 +425,13 @@ class BootstrapMixin(forms.BaseForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(BootstrapMixin, self).__init__(*args, **kwargs)
         super(BootstrapMixin, self).__init__(*args, **kwargs)
+
+        exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect]
+
         for field_name, field in self.fields.items():
         for field_name, field in self.fields.items():
-            if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]:
-                try:
-                    field.widget.attrs['class'] += ' form-control'
-                except KeyError:
-                    field.widget.attrs['class'] = 'form-control'
+            if field.widget.__class__ not in exempt_widgets:
+                css = field.widget.attrs.get('class', '')
+                field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
             if field.required:
             if field.required:
                 field.widget.attrs['required'] = 'required'
                 field.widget.attrs['required'] = 'required'
             if 'placeholder' not in field.widget.attrs:
             if 'placeholder' not in field.widget.attrs:

+ 1 - 1
netbox/utilities/views.py

@@ -174,7 +174,7 @@ class ObjectEditView(View):
 
 
         obj = self.get_object(kwargs)
         obj = self.get_object(kwargs)
         obj = self.alter_obj(obj, request, args, kwargs)
         obj = self.alter_obj(obj, request, args, kwargs)
-        form = self.form_class(request.POST, instance=obj)
+        form = self.form_class(request.POST, request.FILES, instance=obj)
 
 
         if form.is_valid():
         if form.is_valid():
             obj = form.save(commit=False)
             obj = form.save(commit=False)

+ 1 - 0
requirements.txt

@@ -14,6 +14,7 @@ natsort>=5.0.0
 ncclient==0.5.2
 ncclient==0.5.2
 netaddr==0.7.18
 netaddr==0.7.18
 paramiko>=2.0.0
 paramiko>=2.0.0
+Pillow>=4.0.0
 psycopg2>=2.6.1
 psycopg2>=2.6.1
 py-gfm>=0.1.3
 py-gfm>=0.1.3
 pycrypto>=2.6.1
 pycrypto>=2.6.1