diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d0971b556..fae85e2c1 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 @@ -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() @@ -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() @@ -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() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b4731df33..7e9f680de 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 Device, Rack, Site from . import views @@ -22,6 +24,7 @@ urlpatterns = [ 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'), @@ -49,6 +52,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'), @@ -117,6 +121,7 @@ urlpatterns = [ 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/extras/api/serializers.py b/netbox/extras/api/serializers.py index a5c139c08..08da93aa0 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 WritableTopologyMapSerializer(serializers.ModelSerializer): 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 1623dcdeb..85ed93a24 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # 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 09d72fe0b..d8ef9090e 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse 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 @@ -80,6 +80,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): 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/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..478762079 --- /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-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'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 3101757d6..9b31c3db4 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() + 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 # 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..8e4b5918d 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')), @@ -36,6 +38,9 @@ _patterns = [ 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), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2df324a69..4e634d243 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 d6529c2a4..22ae617d3 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -197,6 +197,20 @@ {% endif %}
+
+
+ Images +
+ {% include 'inc/image_attachments.html' with images=rack.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Reservations diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b83139b53..de13d2656 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -235,6 +235,20 @@
{% endif %}
+
+
+ Images +
+ {% include 'inc/image_attachments.html' with images=site.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Topology Maps diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html new file mode 100644 index 000000000..0f922f3c6 --- /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 %} 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/api.py b/netbox/utilities/api.py index 791a504a6..ebcb19d44 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 @@ class ChoiceFieldSerializer(Field): 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). 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