From b643939cc4197fd288b9ef33152cc9ae9de6610f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 21:55:57 -0400 Subject: [PATCH] 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 d0971b556..76d8c7fbc 100644 --- a/netbox/dcim/models.py +++ b/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 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 b4731df33..9e35a1d85 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,6 +3,8 @@ from django.conf.urls import url 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 @@ urlpatterns = [ 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 b4549fcf1..d85697c8d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,9 +3,10 @@ from collections import OrderedDict 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 @@ class CustomFieldFilterForm(forms.Form): 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 000000000..23ed8b786 --- /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 3101757d6..cdf2af31c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -359,6 +359,61 @@ class TopologyMap(models.Model): 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 000000000..6e0e91a0d --- /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 000000000..af0a98745 --- /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 000000000..d6b7ef32c --- /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 aeec93f06..4a486c434 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -153,6 +153,7 @@ TEMPLATES = [ '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 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 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 724ab3090..8a81e3ebb 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 @@ _patterns = [ # 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 @@ if settings.DEBUG: 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 d6529c2a4..4ef8277e2 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -197,6 +197,55 @@ {% 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 21ec67cef..07a39634d 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 dd6235f45..8285a7b96 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 f38d9a0ab..ba29afbe1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -174,7 +174,7 @@ class ObjectEditView(View): 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 b732ab1b1..aa361641b 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