Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#152: Image attachments #1026

Merged
merged 4 commits into from
Apr 3, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -81,6 +81,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