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:
@ -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,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'),
|
||||
|
@ -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
netbox/extras/migrations/0005_add_imageattachment.py
Normal file
34
netbox/extras/migrations/0005_add_imageattachment.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
@ -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
|
||||
#
|
||||
|
12
netbox/extras/urls.py
Normal file
12
netbox/extras/urls.py
Normal 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
30
netbox/extras/views.py
Normal 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()
|
2
netbox/media/image-attachments/.gitignore
vendored
Normal file
2
netbox/media/image-attachments/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -197,6 +197,55 @@
|
||||
{% endif %}
|
||||
</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-heading">
|
||||
<strong>Reservations</strong>
|
||||
|
@ -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 }}
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user