Browse Source

Initial work on #152: Image attachments

Jeremy Stretch 8 years ago
parent
commit
b643939cc4

+ 2 - 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 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()
 

+ 3 - 0
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<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'),

+ 10 - 2
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']

+ 34 - 0
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'],
+            },
+        ),
+    ]

+ 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()
+    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
 #
 

+ 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': [
                 '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
 

+ 3 - 0
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<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
     ]
 
 # Prepend BASE_PATH

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

@@ -199,6 +199,55 @@
         </div>
         <div class="panel panel-default">
             <div class="panel-heading">
+                <strong>Images</strong>
+            </div>
+            {% if rack.images.all %}
+                <table class="table table-hover panel-body">
+                    <tr>
+                        <th>Name</th>
+                        <th>Size</th>
+                        <th>Created</th>
+                        <th></th>
+                    </tr>
+                    {% for attachment in rack.images.all %}
+                        <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 %}
+            {% 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>
             </div>
             {% if reservations %}

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

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

+ 6 - 5
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:

+ 1 - 1
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)

+ 1 - 0
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