From fc045795da381318ba9f5e9640e928f8fefbf2f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 21:55:57 -0400 Subject: [PATCH 1/4] Initial work on #152: Image attachments --- netbox/dcim/models.py | 3 +- netbox/dcim/urls.py | 3 + netbox/extras/forms.py | 12 +++- .../migrations/0005_add_imageattachment.py | 34 ++++++++++++ netbox/extras/models.py | 55 +++++++++++++++++++ netbox/extras/urls.py | 12 ++++ netbox/extras/views.py | 30 ++++++++++ netbox/media/image-attachments/.gitignore | 2 + netbox/netbox/settings.py | 7 ++- netbox/netbox/urls.py | 3 + netbox/templates/dcim/rack.html | 49 +++++++++++++++++ netbox/templates/utilities/obj_edit.html | 2 +- netbox/utilities/forms.py | 11 ++-- netbox/utilities/views.py | 2 +- requirements.txt | 1 + 15 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 netbox/extras/migrations/0005_add_imageattachment.py create mode 100644 netbox/extras/urls.py create mode 100644 netbox/extras/views.py create mode 100644 netbox/media/image-attachments/.gitignore diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d0971b5565..76d8c7fbc9 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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 @@ -375,6 +375,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() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b4731df33e..9e35a1d85c 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,6 +3,8 @@ from ipam.views import ServiceEditView from secrets.views import secret_add +from extras.views import ImageAttachmentEditView +from .models import Rack from . import views @@ -49,6 +51,7 @@ url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), + url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b4549fcf1e..d85697c8d9 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -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, ) @@ -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'] diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py new file mode 100644 index 0000000000..23ed8b786e --- /dev/null +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-30 21:09 +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'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 3101757d69..cdf2af31cb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -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() + obj = 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 # diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py new file mode 100644 index 0000000000..6e0e91a0d0 --- /dev/null +++ b/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\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + +] diff --git a/netbox/extras/views.py b/netbox/extras/views.py new file mode 100644 index 0000000000..af0a98745c --- /dev/null +++ b/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() diff --git a/netbox/media/image-attachments/.gitignore b/netbox/media/image-attachments/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/netbox/media/image-attachments/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index aeec93f069..4a486c434e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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', @@ -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 diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 724ab30900..8a81e3ebba 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -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 @@ -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')), @@ -48,6 +50,7 @@ import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), + url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d6529c2a45..4ef8277e26 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -197,6 +197,55 @@

Rack {{ rack.name }}

{% endif %} +
+
+ Images +
+ {% if rack.images.all %} + + + + + + + + {% for attachment in rack.images.all %} + + + + + + + {% endfor %} +
NameSizeCreated
+ + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
+ {% else %} +
+ None +
+ {% endif %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Reservations diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 21ec67cef4..07a39634dd 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -2,7 +2,7 @@ {% load form_helpers %} {% block content %} -
+ {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index dd6235f453..8285a7b96b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -425,12 +425,13 @@ class BootstrapMixin(forms.BaseForm): def __init__(self, *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(): - 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: field.widget.attrs['required'] = 'required' if 'placeholder' not in field.widget.attrs: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f38d9a0ab6..ba29afbe12 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -174,7 +174,7 @@ def post(self, request, *args, **kwargs): obj = self.get_object(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(): obj = form.save(commit=False) diff --git a/requirements.txt b/requirements.txt index b732ab1b15..aa361641b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ natsort>=5.0.0 ncclient==0.5.2 netaddr==0.7.18 paramiko>=2.0.0 +Pillow>=4.0.0 psycopg2>=2.6.1 py-gfm>=0.1.3 pycrypto>=2.6.1 From cb55119372b62575c990d80dd18d7f411e931b72 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:19:44 -0400 Subject: [PATCH 2/4] Added image attachments to sites and devices --- netbox/dcim/models.py | 2 ++ netbox/dcim/urls.py | 4 ++- netbox/templates/dcim/device.html | 14 ++++++++ netbox/templates/dcim/rack.html | 37 +-------------------- netbox/templates/dcim/site.html | 14 ++++++++ netbox/templates/inc/image_attachments.html | 36 ++++++++++++++++++++ 6 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 netbox/templates/inc/image_attachments.html diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 76d8c7fbc9..fae85e2c1d 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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() @@ -933,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() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 9e35a1d85c..7e9f680de6 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -4,7 +4,7 @@ from secrets.views import secret_add from extras.views import ImageAttachmentEditView -from .models import Rack +from .models import Device, Rack, Site from . import views @@ -24,6 +24,7 @@ url(r'^sites/(?P[\w-]+)/$', views.site, name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P\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'), @@ -120,6 +121,7 @@ url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), + url(r'^devices/(?P\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'), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2df324a69d..4e634d243b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -326,6 +326,20 @@ {% endif %}
+
+
+ Images +
+ {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Related Devices diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4ef8277e26..22ae617d36 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -201,42 +201,7 @@

Rack {{ rack.name }}

Images
- {% if rack.images.all %} - - - - - - - - {% for attachment in rack.images.all %} - - - - - - - {% endfor %} -
NameSizeCreated
- - {{ attachment }} - {{ attachment.image.size|filesizeformat }}{{ attachment.created }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
- {% else %} -
- None -
- {% endif %} + {% include 'inc/image_attachments.html' with images=rack.images.all %} {% if perms.extras.add_imageattachment %}
Topology Maps diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html new file mode 100644 index 0000000000..0f922f3c62 --- /dev/null +++ b/netbox/templates/inc/image_attachments.html @@ -0,0 +1,36 @@ +{% if images %} + + + + + + + + {% for attachment in images %} + + + + + + + {% endfor %} +
NameSizeCreated
+ + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
+{% else %} +
+ None +
+{% endif %} From 342fba9d1c5797821ca27b7347a79343edcab152 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:51:17 -0400 Subject: [PATCH 3/4] Enable serving static media through Django --- netbox/netbox/urls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 8a81e3ebba..8e4b5918db 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -38,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.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + # Error testing url(r'^500/$', trigger_500), @@ -50,7 +53,6 @@ import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), - url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH From d1e7a187425658006f56f7548b8679cf1b20fa79 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:00:15 -0400 Subject: [PATCH 4/4] Implemented API endpoints for ImageAttachments --- netbox/extras/api/serializers.py | 57 ++++++++++++++++++- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 8 ++- .../migrations/0005_add_imageattachment.py | 2 +- netbox/extras/models.py | 2 +- netbox/utilities/api.py | 18 +++++- 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a5c139c082..08da93aa06 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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 # @@ -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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1623dcdeb9..85ed93a245 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fab1ccdb5b..d5b05fab45 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 @@ -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. diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py index 23ed8b786e..4787620798 100644 --- a/netbox/extras/migrations/0005_add_imageattachment.py +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-30 21:09 +# Generated by Django 1.10.6 on 2017-04-03 15:55 from __future__ import unicode_literals from django.db import migrations, models diff --git a/netbox/extras/models.py b/netbox/extras/models.py index cdf2af31cb..9b31c3db4d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -384,7 +384,7 @@ class ImageAttachment(models.Model): """ content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() - obj = GenericForeignKey('content_type', 'object_id') + 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() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ca4384f08f..b7dd61d0f4 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,9 +1,10 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException 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 @@ -79,6 +80,21 @@ def to_internal_value(self, data): return self._choices.get(data) +class ContentTypeFieldSerializer(Field): + """ + Represent a ContentType as '.' + """ + 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): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).