Skip to content

Commit

Permalink
Merge pull request netbox-community#1026 from digitalocean/image-atta…
Browse files Browse the repository at this point in the history
…chments

netbox-community#152: Image attachments
  • Loading branch information
jeremystretch authored Apr 3, 2017
2 parents 6cfcb37 + d1e7a18 commit d3e1354
Show file tree
Hide file tree
Showing 22 changed files with 330 additions and 17 deletions.
5 changes: 4 additions & 1 deletion netbox/dcim/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from django.utils.encoding import python_2_unicode_compatible

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 tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField
Expand Down Expand Up @@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)

objects = SiteManager()

Expand Down Expand Up @@ -375,6 +376,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)

objects = RackManager()

Expand Down Expand Up @@ -932,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
blank=True, null=True, verbose_name='Primary IPv6')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)

objects = DeviceManager()

Expand Down
5 changes: 5 additions & 0 deletions netbox/dcim/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from ipam.views import ServiceEditView
from secrets.views import secret_add

from extras.views import ImageAttachmentEditView
from .models import Device, Rack, Site
from . import views


Expand All @@ -22,6 +24,7 @@
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-]+)/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
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
Expand Down Expand Up @@ -49,6 +52,7 @@
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<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
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
Expand Down Expand Up @@ -117,6 +121,7 @@
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<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
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
Expand Down
57 changes: 54 additions & 3 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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 utilities.api import ChoiceFieldSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer


#
Expand Down Expand Up @@ -71,6 +76,52 @@ class Meta:
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']


#
# 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
#
Expand Down
3 changes: 3 additions & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ def get_view_name(self):
# Topology maps
router.register(r'topology-maps', views.TopologyMapViewSet)

# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)

# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)

Expand Down
8 changes: 7 additions & 1 deletion netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.shortcuts import get_object_or_404

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 . import serializers

Expand Down Expand Up @@ -80,6 +80,12 @@ def render(self, request, pk):
return response


class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer


class RecentActivityViewSet(ReadOnlyModelViewSet):
"""
List all UserActions to provide a log of recent activity.
Expand Down
12 changes: 10 additions & 2 deletions netbox/extras/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from django import forms
from django.contrib.contenttypes.models import ContentType

from utilities.forms import BulkEditForm, LaxURLField
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
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,
)


Expand Down Expand Up @@ -158,3 +159,10 @@ def __init__(self, *args, **kwargs):
for name, field in custom_fields:
field.required = False
self.fields[name] = field


class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):

class Meta:
model = ImageAttachment
fields = ['name', 'image']
34 changes: 34 additions & 0 deletions netbox/extras/migrations/0005_add_imageattachment.py
Original file line number Diff line number Diff line change
@@ -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 changes: 55 additions & 0 deletions netbox/extras/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,61 @@ def render(self, img_format='png'):
return graph.pipe(format=img_format)


#
# 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
#
Expand Down
12 changes: 12 additions & 0 deletions netbox/extras/urls.py
Original file line number Diff line number Diff line change
@@ -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 changes: 30 additions & 0 deletions netbox/extras/views.py
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions netbox/media/image-attachments/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
7 changes: 5 additions & 2 deletions netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.template.context_processors.media',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings',
Expand All @@ -167,19 +168,21 @@
USE_X_FORWARDED_HOST = True

# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
USE_I18N = True
USE_TZ = True

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_ROOT = BASE_DIR + '/static/'
STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = (
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.)
DATA_UPLOAD_MAX_NUMBER_FIELDS = None

Expand Down
5 changes: 5 additions & 0 deletions netbox/netbox/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve

from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
from users.views import login, logout
Expand All @@ -21,6 +22,7 @@
# Apps
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
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'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
Expand All @@ -36,6 +38,9 @@
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
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
url(r'^500/$', trigger_500),

Expand Down
Loading

0 comments on commit d3e1354

Please sign in to comment.