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

Merge pull request #1026 from digitalocean/image-attachments

#152: Image attachments
This commit is contained in:
Jeremy Stretch
2017-04-03 14:21:15 -04:00
committed by GitHub
22 changed files with 330 additions and 17 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
@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
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 = SiteManager() objects = SiteManager()
@ -375,6 +376,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()
@ -932,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
blank=True, null=True, verbose_name='Primary IPv6') blank=True, null=True, verbose_name='Primary IPv6')
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 = DeviceManager() objects = DeviceManager()

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 Device, Rack, Site
from . import views from . import views
@ -22,6 +24,7 @@ urlpatterns = [
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'), url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups # Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
@ -49,6 +52,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'),
@ -117,6 +121,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports # Console ports
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),

View File

@ -1,9 +1,14 @@
from rest_framework import serializers from rest_framework import serializers
from dcim.api.serializers import NestedSiteSerializer from django.core.exceptions import ObjectDoesNotExist
from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction
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 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'] 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 # User actions
# #

View File

@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
# Topology maps # Topology maps
router.register(r'topology-maps', views.TopologyMapViewSet) router.register(r'topology-maps', views.TopologyMapViewSet)
# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Recent activity # Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet) router.register(r'recent-activity', views.RecentActivityViewSet)

View File

@ -6,7 +6,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from extras import filters 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 utilities.api import WritableSerializerMixin
from . import serializers from . import serializers
@ -80,6 +80,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
return response return response
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer
class RecentActivityViewSet(ReadOnlyModelViewSet): class RecentActivityViewSet(ReadOnlyModelViewSet):
""" """
List all UserActions to provide a log of recent activity. List all UserActions to provide a log of recent activity.

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

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()
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 # 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')),
@ -36,6 +38,9 @@ _patterns = [
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api/docs/', include('rest_framework_swagger.urls')),
# Serving static media in Django to pipe it through LoginRequiredMiddleware
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
# Error testing # Error testing
url(r'^500/$', trigger_500), url(r'^500/$', trigger_500),

View File

@ -326,6 +326,20 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Images</strong>
</div>
{% include 'inc/image_attachments.html' with images=device.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:device_add_image' object_id=device.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>Related Devices</strong> <strong>Related Devices</strong>

View File

@ -197,6 +197,20 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Images</strong>
</div>
{% include 'inc/image_attachments.html' with images=rack.images.all %}
{% 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

@ -235,6 +235,20 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Images</strong>
</div>
{% include 'inc/image_attachments.html' with images=site.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:site_add_image' object_id=site.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>Topology Maps</strong> <strong>Topology Maps</strong>

View File

@ -0,0 +1,36 @@
{% if images %}
<table class="table table-hover panel-body">
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
{% for attachment in images %}
<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 %}

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

@ -1,9 +1,10 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS 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 from users.models import Token
@ -79,6 +80,21 @@ class ChoiceFieldSerializer(Field):
return self._choices.get(data) return self._choices.get(data)
class ContentTypeFieldSerializer(Field):
"""
Represent a ContentType as '<app_label>.<model>'
"""
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): class WritableSerializerMixin(object):
""" """
Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).

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