1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on #152: Image attachments

This commit is contained in:
Jeremy Stretch
2017-03-30 21:55:57 -04:00
parent 3ed3e93b25
commit b643939cc4
15 changed files with 214 additions and 12 deletions

View File

@ -15,7 +15,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from circuits.models import Circuit 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 extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField from utilities.fields import ColorField, NullableCharField
@ -375,6 +375,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
help_text='Units are numbered top-to-bottom') help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
objects = RackManager() objects = RackManager()

View File

@ -3,6 +3,8 @@ from django.conf.urls import url
from ipam.views import ServiceEditView from ipam.views import ServiceEditView
from secrets.views import secret_add from secrets.views import secret_add
from extras.views import ImageAttachmentEditView
from .models import Rack
from . import views 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+)/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<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<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 # Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),

View File

@ -3,9 +3,10 @@ from collections import OrderedDict
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from utilities.forms import BulkEditForm, LaxURLField from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .models import ( 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: for name, field in custom_fields:
field.required = False field.required = False
self.fields[name] = field self.fields[name] = field
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ImageAttachment
fields = ['name', 'image']

View File

@ -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'],
},
),
]

View File

@ -359,6 +359,61 @@ class TopologyMap(models.Model):
return graph.pipe(format=img_format) 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 # User actions
# #

12
netbox/extras/urls.py Normal file
View File

@ -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
netbox/extras/views.py Normal file
View File

@ -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()

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -153,6 +153,7 @@ TEMPLATES = [
'context_processors': [ 'context_processors': [
'django.template.context_processors.debug', 'django.template.context_processors.debug',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.template.context_processors.media',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings', 'utilities.context_processors.settings',
@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True USE_X_FORWARDED_HOST = True
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_ROOT = BASE_DIR + '/static/' STATIC_ROOT = BASE_DIR + '/static/'
STATIC_URL = '/{}static/'.format(BASE_PATH) STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = ( STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"), 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.) # 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 DATA_UPLOAD_MAX_NUMBER_FIELDS = None

View File

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from django.views.static import serve
from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500 from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
from users.views import login, logout from users.views import login, logout
@ -21,6 +22,7 @@ _patterns = [
# Apps # Apps
url(r'^circuits/', include('circuits.urls', namespace='circuits')), url(r'^circuits/', include('circuits.urls', namespace='circuits')),
url(r'^dcim/', include('dcim.urls', namespace='dcim')), 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'^ipam/', include('ipam.urls', namespace='ipam')),
url(r'^secrets/', include('secrets.urls', namespace='secrets')), url(r'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')), url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
@ -48,6 +50,7 @@ if settings.DEBUG:
import debug_toolbar import debug_toolbar
_patterns += [ _patterns += [
url(r'^__debug__/', include(debug_toolbar.urls)), url(r'^__debug__/', include(debug_toolbar.urls)),
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
] ]
# Prepend BASE_PATH # Prepend BASE_PATH

View File

@ -197,6 +197,55 @@
{% endif %} {% endif %}
</div> </div>
</div> </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 panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Reservations</strong> <strong>Reservations</strong>

View File

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

View File

@ -425,12 +425,13 @@ class BootstrapMixin(forms.BaseForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(BootstrapMixin, self).__init__(*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(): for field_name, field in self.fields.items():
if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]: if field.widget.__class__ not in exempt_widgets:
try: css = field.widget.attrs.get('class', '')
field.widget.attrs['class'] += ' form-control' field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
except KeyError:
field.widget.attrs['class'] = 'form-control'
if field.required: if field.required:
field.widget.attrs['required'] = 'required' field.widget.attrs['required'] = 'required'
if 'placeholder' not in field.widget.attrs: if 'placeholder' not in field.widget.attrs:

View File

@ -174,7 +174,7 @@ class ObjectEditView(View):
obj = self.get_object(kwargs) obj = self.get_object(kwargs)
obj = self.alter_obj(obj, request, args, 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(): if form.is_valid():
obj = form.save(commit=False) obj = form.save(commit=False)

View File

@ -14,6 +14,7 @@ natsort>=5.0.0
ncclient==0.5.2 ncclient==0.5.2
netaddr==0.7.18 netaddr==0.7.18
paramiko>=2.0.0 paramiko>=2.0.0
Pillow>=4.0.0
psycopg2>=2.6.1 psycopg2>=2.6.1
py-gfm>=0.1.3 py-gfm>=0.1.3
pycrypto>=2.6.1 pycrypto>=2.6.1