diff --git a/docs/settings.rst b/docs/settings.rst index 98d3ef3c8..46634c10a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -6,7 +6,7 @@ Settings ``FILER_ENABLE_PERMISSIONS`` ---------------------------- -Activate the or not the Permission check on the files and folders before +Activate the or not the Permission check on the files and folders before displaying them in the admin. When set to ``False`` it gives all the authorization to staff members based on standard Django model permissions. @@ -73,7 +73,7 @@ Public storage uses ``DEFAULT_FILE_STORAGE`` as default storage backend. ``UPLOAD_TO`` is the function to generate the path relative to the storage root. The default generates a random path like ``1d/a5/1da50fee-5003-46a1-a191-b547125053a8/filename.jpg``. This -will be applied whenever a file is uploaded or moved between public (without permission checks) and +will be applied whenever a file is uploaded or moved between public (without permission checks) and private (with permission checks) storages. Defaults to ``'filer.utils.generate_filename.randomized'``. @@ -111,7 +111,7 @@ Defaults to ``20`` ``FILER_SUBJECT_LOCATION_IMAGE_DEBUG`` -------------------------------------- -Draws a red circle around to point in the image that was used to do the +Draws a red circle around to point in the image that was used to do the subject location aware image cropping. Defaults to ``False`` @@ -150,3 +150,30 @@ Number of simultaneous AJAX uploads. Defaults to 3. If your database backend is SQLite it would be set to 1 by default. This allows to avoid ``database is locked`` errors on SQLite during multiple simultaneous file uploads. + + +``FILER_DEFAULT_FOLDER_GETTER`` +------------------------------- + +Path to a subclass of `filer.utils.folders.DefaultFolderGetter`. +Methods name of this subclass can be used as value for the +`default_folder_key` of ``FilerFileField`` and ``FilerImageField``. + +e.g:: + + FILER_DEFAULT_FOLDER_GETTER = 'myapp.handlers.FolderGetter' + +and in myapp/hanlers.py:: + + from filer.utils.folders import DefaultFolderGetter + + class FolderGetter(DefaultFolderGetter): + @classmethod + def USER_OWN_FOLDER(cls, request): + if not request.user.is_authenticated(): + return None + parent_kwargs = { + name: 'users_files', + + } + return cls._get_or_create(name=user.username, owner=user, parent_kwargs=parent_kwargs) diff --git a/docs/usage.rst b/docs/usage.rst index e119d773f..025692ea0 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -24,6 +24,28 @@ checksums. company.logo.icons['64'] # or {{ company.logo.icons.64 }} in a template company.logo.url # prints path to original image +``FileMimetypeValidator`` and preconfigured validators +------------------------------------------------------ + +``django-filer`` provides a validator to allow only files of some mimetypes. +``FileMimetypeValidator`` require a mimetypes list and allow wildcard for +subtypes. eg : `image/jpeg` allow only JPEG files, but if you want to allow all +image types, `image/*` is accepted. + +Some preconfigured validators are set: + +* `validate_audios` : allow all `audio/*` files +* `validate_images` : allow all `images/*` files +* `validate_videos` : allow all `video/*` files +* `validate_html5_audios` : allow most supported audio file format for web integration + (wav, mp3, mp4, ogg, webm, aac) +* `validate_html5_images` : allow most supported image file format for web integration + (jpeg, png, gif, svg) +* `validate_html5_videos` : allow most supported video file format for web integration + (mp4, ogv, webm) +* `validate_documents` : allow main document types + (odt, ods, odpn, doc, xls, ppt, pdf, csv) + ``FilerFileField`` and ``FilerImageField`` ------------------------------------------ @@ -33,6 +55,17 @@ The only difference is, that there is no need to declare what model we are referencing (it is always ``filer.models.File`` for the ``FilerFileField`` and ``filer.models.Image`` for the ``FilerImageField``). +Those Fields have some specific and optionnal options : + +* `default_folder_key` : specify the folder handler key which will return the +folder where files will be stored for direct upload, or the folder to open +when we use file lookup. +* `default_direct_upload_enabled` : Allow use to upload a file inside the + main form (without opening the files lookup popup). Default is False + to stay backward compatible. +* `default_file_lookup_enabled` : Allow user to choose (or add) files via + the file lookup popup (default is True) + Simple example ``models.py``:: from django.db import models @@ -60,6 +93,30 @@ As with `django.db.models.ForeignKey`_ in general, you have to define a non-clashing ``related_name`` if there are multiple ``ForeignKey`` s to the same model. + +Advanced exemple ``models.py``:: + + from django.db import models + from filer.fields.image import FilerImageField + from filer.fields.file import FilerFileField + from filer.validators import FileMimetypeValidator, validate_documents + + class Company(models.Model): + name = models.CharField(max_length=255) + logo = FilerImageField(null=True, blank=True, + help_text='JPEG only', + default_direct_upload_enabled=True, + default_file_lookup_enabled=False, + default_folder_key='USERS_OWN_FOLDER', + validators=[FileMimetypeValidator(['image/jpeg',]),], + related_name="company_logo") + disclaimer = FilerFileField(null=True, blank=True, + default_direct_upload_enabled=True, + default_file_lookup_enabled=True, + default_folder_key='DOCUMENTS', + validators=[validate_documents,] + related_name="company_disclaimer") + templates ......... diff --git a/filer/admin/clipboardadmin.py b/filer/admin/clipboardadmin.py index ab35affb2..96ef5e59d 100644 --- a/filer/admin/clipboardadmin.py +++ b/filer/admin/clipboardadmin.py @@ -8,11 +8,12 @@ from filer import settings as filer_settings from filer.models import Folder, Clipboard, ClipboardItem, Image -from filer.utils.compatibility import DJANGO_1_4 +from filer.utils.compatibility import DJANGO_1_4, get_model from filer.utils.files import ( handle_upload, handle_request_files_upload, UploadException, ) from filer.utils.loader import load_object +from filer.validators import FileMimetypeValidator NO_FOLDER_ERROR = "Can't find folder to upload. Please refresh and try again" @@ -53,7 +54,16 @@ def get_urls(self): url(r'^operations/upload/(?P[0-9]+)/$', ajax_upload, name='filer-ajax_upload'), - url(r'^operations/upload/no_folder/$', + url((r'^operations/upload/' + r'(?P\w*)/' + r'(?P\w+.\w+.\w+)/' + r'$'), + ajax_upload, + name='filer-ajax_upload'), + url((r'^operations/upload/(?P\w*)/$'), + ajax_upload, + name='filer-ajax_upload'), + url((r'^operations/upload/no_folder/$'), ajax_upload, name='filer-ajax_upload'), ) @@ -72,34 +82,48 @@ def get_model_perms(self, *args, **kwargs): @csrf_exempt -def ajax_upload(request, folder_id=None): +def ajax_upload(request, folder_id=None, folder_key=None, related_field=None): """ Receives an upload from the uploader. Receives only one file at a time. """ mimetype = "application/json" if request.is_ajax() else "text/html" content_type_key = 'mimetype' if DJANGO_1_4 else 'content_type' response_params = {content_type_key: mimetype} - folder = None - if folder_id: - try: - # Get folder - folder = Folder.objects.get(pk=folder_id) - except Folder.DoesNotExist: - return HttpResponse(json.dumps({'error': NO_FOLDER_ERROR}), - **response_params) - - # check permissions - if folder and not folder.has_add_children_permission(request): - return HttpResponse( - json.dumps({'error': NO_PERMISSIONS_FOR_FOLDER}), - **response_params) + mimetypes = [] try: + if related_field: + related_field = related_field.split('.') + if len(related_field) != 3: + raise UploadException("Related field is not valid.") + try: + model = get_model(related_field[0], related_field[1]) + field = model._meta.get_field_by_name(related_field[2])[0] + except: + raise UploadException("Related field is not valid.") + for validator in field.validators: + if isinstance(validator, FileMimetypeValidator): + mimetypes += validator.mimetypes + folder = None + if folder_id: + try: + # Get folder + folder = Folder.objects.get(pk=folder_id) + except Folder.DoesNotExist: + return HttpResponse(json.dumps({'error': NO_FOLDER_ERROR}), + **response_params) + elif folder_key and folder_key != 'no_folder': + from filer.utils.folders import get_default_folder_getter + folder = get_default_folder_getter().get(folder_key, request) + + # check permissions + if folder and not folder.has_add_children_permission(request): + raise UploadException(NO_PERMISSIONS_FOR_FOLDER) if len(request.FILES) == 1: # dont check if request is ajax or not, just grab the file - upload, filename, is_raw = handle_request_files_upload(request) + upload, filename, is_raw = handle_request_files_upload(request, mimetypes) else: # else process the request as usual - upload, filename, is_raw = handle_upload(request) + upload, filename, is_raw = handle_upload(request, mimetypes) # TODO: Deprecated/refactor # Get clipboad # clipboard = Clipboard.objects.get_or_create(user=request.user)[0] @@ -175,8 +199,8 @@ def ajax_upload(request, folder_id=None): **response_params) else: form_errors = '; '.join(['%s: %s' % ( - field, - ', '.join(errors)) for field, errors in list( + fieldname, + ', '.join(errors)) for fieldname, errors in list( uploadform.errors.items()) ]) raise UploadException( @@ -184,5 +208,5 @@ def ajax_upload(request, folder_id=None): form_errors,)) except UploadException as e: return HttpResponse(json.dumps({'error': str(e)}), - status=500, + # status=500, we know what's going on and must display it to the user **response_params) diff --git a/filer/admin/folderadmin.py b/filer/admin/folderadmin.py index 2d167de17..dd6b8e79a 100644 --- a/filer/admin/folderadmin.py +++ b/filer/admin/folderadmin.py @@ -205,7 +205,9 @@ def get_urls(self): url(r'^(?P\d+)/list/$', self.admin_site.admin_view(self.directory_listing), name='filer-directory_listing'), - + url(r'^(?P[a-zA-Z][a-zA-Z0-9_]*)/list/$', + self.admin_site.admin_view(self.directory_listing_by_key), + name='filer-directory_listing_by_key'), url(r'^(?P\d+)/make_folder/$', self.admin_site.admin_view(views.make_folder), name='filer-directory_listing-make_folder'), @@ -227,6 +229,11 @@ def get_urls(self): return url_patterns # custom views + def directory_listing_by_key(self, request, folder_key): + from filer.utils.folders import get_default_folder_getter + folder = get_default_folder_getter().get(folder_key, request) + return self.directory_listing(request, folder.pk) + def directory_listing(self, request, folder_id=None, viewtype=None): clipboard = tools.get_user_clipboard(request.user) if viewtype == 'images_with_missing_data': diff --git a/filer/fields/file.py b/filer/fields/file.py index 923b8a29b..d639c4e10 100644 --- a/filer/fields/file.py +++ b/filer/fields/file.py @@ -5,15 +5,17 @@ from django import forms from django.contrib.admin.widgets import ForeignKeyRawIdWidget from django.contrib.admin.sites import site -from django.contrib.staticfiles.templatetags.staticfiles import static from django.core.urlresolvers import reverse from django.db import models from django.template.loader import render_to_string from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _, ungettext_lazy + from filer.utils.compatibility import truncate_words from filer.utils.model_label import get_model_label from filer.models import File +from filer.validators import FileMimetypeValidator from filer import settings as filer_settings import logging @@ -21,9 +23,16 @@ class AdminFileWidget(ForeignKeyRawIdWidget): + template = 'admin/filer/widgets/admin_file.html' choices = None - def render(self, name, value, attrs=None): + def __init__(self, rel, site, *args, **kwargs): + self.file_lookup_enabled = kwargs.pop('file_lookup_enabled', True) + self.direct_upload_enabled = kwargs.pop('direct_upload_enabled', True) + self.folder_key = kwargs.pop('folder_key', None) + super(AdminFileWidget, self).__init__(rel, site, *args, **kwargs) + + def get_context(self, name, value, attrs=None): obj = self.obj_for_value(value) css_id = attrs.get('id', 'id_image_x') css_id_thumbnail_img = "%s_thumbnail_img" % css_id @@ -41,7 +50,13 @@ def render(self, name, value, attrs=None): logger.error('Error while rendering file widget: %s', e) if filer_settings.FILER_DEBUG: raise - if not related_url: + + if self.folder_key: + related_url = reverse( + 'admin:filer-directory_listing_by_key', + kwargs={'folder_key': self.folder_key} + ) + elif not related_url: related_url = reverse('admin:filer-directory_listing-last') params = self.url_parameters() if params: @@ -54,6 +69,7 @@ def render(self, name, value, attrs=None): # rendering the super for ForeignKeyRawIdWidget on purpose here because # we only need the input and none of the other stuff that # ForeignKeyRawIdWidget adds + attrs['type'] = 'hidden' hidden_input = super(ForeignKeyRawIdWidget, self).render(name, value, attrs) context = { 'hidden_input': hidden_input, @@ -64,8 +80,33 @@ def render(self, name, value, attrs=None): 'lookup_name': name, 'clear_id': '%s_clear' % css_id, 'id': css_id, + 'file_lookup_enabled': self.file_lookup_enabled, + 'direct_upload_enabled': self.direct_upload_enabled, } - html = render_to_string('admin/filer/widgets/admin_file.html', context) + if hasattr(self.rel, 'field'): + related_field = '%s.%s.%s' % ( + self.rel.field.model._meta.app_label, + self.rel.field.model.__name__, + self.rel.field.name, + ) + direct_upload_url = reverse('admin:filer-ajax_upload', + kwargs={'related_field': related_field, + 'folder_key': self.folder_key}) + + context['direct_upload_related_field'] = related_field + else: + direct_upload_url = reverse('admin:filer-ajax_upload', + kwargs={'folder_key': self.folder_key}) + + context.update({ + 'folder_key': self.folder_key or 'no_folder', + 'direct_upload_url': direct_upload_url, + }) + return context + + def render(self, name, value, attrs=None): + context = self.get_context(name, value, attrs) + html = render_to_string(self.template, context) return mark_safe(html) def label_for_value(self, value): @@ -80,11 +121,25 @@ def obj_for_value(self, value): obj = None return obj - class Media(object): - js = ( - static('filer/js/addons/popup_handling.js'), - static('filer/js/addons/widget.js'), - ) + @property + def media(self): + kwargs = { + 'css': { + 'all': ('css/admin_style.css',), + }, + 'js': [ + 'filer/js/libs/jquery.min.js', + 'filer/js/addons/widget.js', + ], + } + if self.direct_upload_enabled: + kwargs['js'] += [ + 'filer/js/libs/dropzone.min.js', + 'filer/js/addons/dropzone.init.js', + ] + if self.file_lookup_enabled: + kwargs['js'].append('filer/js/addons/popup_handling.js') + return super(AdminFileWidget, self).media + forms.Media(**kwargs) class AdminFileFormField(forms.ModelChoiceField): @@ -97,7 +152,37 @@ def __init__(self, rel, queryset, to_field_name, *args, **kwargs): self.max_value = None self.min_value = None kwargs.pop('widget', None) - super(AdminFileFormField, self).__init__(queryset, widget=self.widget(rel, site), *args, **kwargs) + widgetkwargs = { + 'file_lookup_enabled': kwargs.pop('file_lookup_enabled', True), + 'direct_upload_enabled': kwargs.pop('direct_upload_enabled', False), + 'folder_key': kwargs.pop('folder_key', None), + } + + super(AdminFileFormField, self).__init__( + queryset, + widget=self.widget(rel, site, **widgetkwargs), + *args, **kwargs + ) + + if not self.help_text and hasattr(self.rel, 'field'): + validators = self.validators + self.rel.field.validators + for validator in validators: + if isinstance(validator, FileMimetypeValidator): + if len(validator.mimetypes) > 1: + mimetypes = _('%s" and "%s') % ( + '", "'.join(validator.mimetypes[0:-1]), + validator.mimetypes[-1] + ) + else: + mimetypes = validator.mimetypes[0] + self.help_text = ungettext_lazy( + 'Only files of type "%(mimetypes)s" are allowed', + 'Only files of types "%(mimetypes)s" are allowed', + len(validator.mimetypes) + ) % { + 'mimetypes': mimetypes + } + break def widget_attrs(self, widget): widget.required = self.required @@ -119,15 +204,22 @@ def __init__(self, **kwargs): ) warnings.warn(msg, SyntaxWarning) kwargs['to'] = dfl + default_keys = ( + 'form_class', 'file_lookup_enabled', + 'direct_upload_enabled', 'folder_key' + ) + self.default_formfield_kwargs = {'form_class': self.default_form_class, } + for key in default_keys: + default_key = 'default_%s' % key + if default_key in kwargs: + self.default_formfield_kwargs[key] = kwargs.pop(default_key) super(FilerFileField, self).__init__(**kwargs) def formfield(self, **kwargs): # This is a fairly standard way to set up some defaults # while letting the caller override them. - defaults = { - 'form_class': self.default_form_class, - 'rel': self.rel, - } + defaults = {'rel': self.rel, } + defaults.update(self.default_formfield_kwargs) defaults.update(kwargs) return super(FilerFileField, self).formfield(**defaults) diff --git a/filer/fields/image.py b/filer/fields/image.py index 4e47a5336..a725840d2 100644 --- a/filer/fields/image.py +++ b/filer/fields/image.py @@ -4,6 +4,7 @@ AdminFileWidget, AdminFileFormField, FilerFileField ) from filer.models import Image +from filer.validators import validate_images class AdminImageWidget(AdminFileWidget): @@ -17,3 +18,4 @@ class AdminImageFormField(AdminFileFormField): class FilerImageField(FilerFileField): default_form_class = AdminImageFormField default_model_class = Image + default_validators = [validate_images, ] diff --git a/filer/private/sass/admin_filer.scss b/filer/private/sass/admin_filer.scss index 7c65f4bb0..246130d6f 100755 --- a/filer/private/sass/admin_filer.scss +++ b/filer/private/sass/admin_filer.scss @@ -19,9 +19,10 @@ @import "components/navigator"; @import "components/modal"; @import "components/drag-and-drop"; +@import "components/direct-upload"; //############################################################################## // IMPORT LIBS @import "libs/bootstrap.custom.min"; @import "libs/font-awesome.min"; -@import "libs/dropzone"; \ No newline at end of file +@import "libs/dropzone"; diff --git a/filer/private/sass/components/_direct-upload.scss b/filer/private/sass/components/_direct-upload.scss new file mode 100644 index 000000000..46afaa512 --- /dev/null +++ b/filer/private/sass/components/_direct-upload.scss @@ -0,0 +1,19 @@ +.fileUploading { + .filerProgress{ + display: inline-block !important; + margin: 2px 5px; + } +} +.filerUploader { + span{ + display:inline-block; + } + > span{ + position:relative; + } +} +.filerUploader > span input[type=file], +.fileSelected .filerFilename, +.fileUploading .filerChoose{ + display:none; +} diff --git a/filer/settings.py b/filer/settings.py index cab8cd48b..e498412e9 100644 --- a/filer/settings.py +++ b/filer/settings.py @@ -242,3 +242,4 @@ def update_server_settings(settings, defaults, s, t): FILER_DUMP_PAYLOAD = getattr(settings, 'FILER_DUMP_PAYLOAD', False) # Whether the filer shall dump the files payload FILER_CANONICAL_URL = getattr(settings, 'FILER_CANONICAL_URL', 'canonical/') +FILER_DEFAULT_FOLDER_GETTER = getattr(settings, 'FILER_DEFAULT_FOLDER_GETTER', 'filer.utils.folders.DefaultFolderGetter') diff --git a/filer/static/filer/js/addons/dropzone.init.js b/filer/static/filer/js/addons/dropzone.init.js index c970c9ed1..760130873 100644 --- a/filer/static/filer/js/addons/dropzone.init.js +++ b/filer/static/filer/js/addons/dropzone.init.js @@ -26,12 +26,16 @@ element.toggleClass(mobileClass, element.width() < minWidth); }; var showError = function (message) { - try { - window.parent.CMS.API.Messages.open({ - message: message - }); - } catch (errorText) { - console.log(errorText); + if (window.parent && window.parent.CMS) { + try { + window.parent.CMS.API.Messages.open({ + message: message + }); + } catch (errorText) { + console.log(errorText); + } + } else { + alert(message); } }; diff --git a/filer/static/filer/js/addons/widget.js b/filer/static/filer/js/addons/widget.js index 8c112be32..08b1172be 100644 --- a/filer/static/filer/js/addons/widget.js +++ b/filer/static/filer/js/addons/widget.js @@ -22,9 +22,12 @@ }; $(document).ready(function () { - $('.filerFile .vForeignKeyRawIdAdminField').attr('type', 'hidden'); + $('.filerFile').each(function () { + $(this).find('.vForeignKeyRawIdAdminField').hide(); + $('#add_' + $(this).data('id')).remove(); + }); //if this file is included multiple time, we ensure that filer_clear is attached only once. $(document).off('click.filer', '.filerFile .filerClearer', filer_clear) .on('click.filer', '.filerFile .filerClearer', filer_clear); }); -})(django.jQuery); +})(window.$ || django.jQuery); diff --git a/filer/templates/admin/filer/widgets/admin_file.html b/filer/templates/admin/filer/widgets/admin_file.html index 20ee5dd00..b51271bb3 100644 --- a/filer/templates/admin/filer/widgets/admin_file.html +++ b/filer/templates/admin/filer/widgets/admin_file.html @@ -1,6 +1,8 @@ {% load i18n filer_admin_tags staticfiles %} {% spaceless %} + +{% if direct_upload_enabled %} -
+
- {% trans "or drop your file here" %} + + {% if file_lookup_enabled %} + {% trans "or drop your file here" %} + {% else %} + {% trans "drop your file here" %} + {% endif %}
- - +{% endif %} + {% if object %} {% if object.icons.32 %} {{ object.label }} @@ -30,30 +37,21 @@   {% endif %} - - {% trans 'Choose File' %} - + {% if file_lookup_enabled %} + + {% trans 'Choose File' %} + + {% endif %}
{{ hidden_input }} -
+ +{% if direct_upload_enabled %}
-{% endspaceless %} +{% endif %} - - - - +{% endspaceless %} \ No newline at end of file diff --git a/filer/test_utils/thirdparty_app/__init__.py b/filer/test_utils/thirdparty_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/filer/test_utils/thirdparty_app/admin.py b/filer/test_utils/thirdparty_app/admin.py new file mode 100644 index 000000000..fa8c14e6d --- /dev/null +++ b/filer/test_utils/thirdparty_app/admin.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +from filer.test_utils.thirdparty_app.models import Example + + +class ExampleAdmin(admin.ModelAdmin): + pass + +admin.site.register(Example, ExampleAdmin) diff --git a/filer/test_utils/thirdparty_app/handlers.py b/filer/test_utils/thirdparty_app/handlers.py new file mode 100644 index 000000000..6d5db07ec --- /dev/null +++ b/filer/test_utils/thirdparty_app/handlers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from filer.utils.folders import DefaultFolderGetter + + +class CustomFolderGetter(DefaultFolderGetter): + + @classmethod + def USER_OWN_FOLDER(cls, request): + user = request.user + if not user.is_authenticated(): + return None + return cls._get_or_create(name=user.username, + owner=user, + parent_kwargs={'name': 'users_files', 'parent': None}) + + @classmethod + def IMAGES(cls, request): + user = request.user + if not user.is_authenticated(): + return None + return cls._get_or_create(name='Images') + + @classmethod + def DOCUMENTS(cls, request): + user = request.user + if not user.is_authenticated(): + return None + return cls._get_or_create(name='Documents') diff --git a/filer/test_utils/thirdparty_app/migrations/0001_initial.py b/filer/test_utils/thirdparty_app/migrations/0001_initial.py new file mode 100644 index 000000000..794d4946e --- /dev/null +++ b/filer/test_utils/thirdparty_app/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from filer.settings import FILER_IMAGE_MODEL +import filer.fields.file +import filer.fields.image +import filer.validators + + +class Migration(migrations.Migration): + + if FILER_IMAGE_MODEL: + dependencies = [ + ('filer', '0002_auto_20150606_2003'), + ('custom_image', '0001_initial'), + ] + else: + dependencies = [ + ('filer', '0002_auto_20150606_2003'), + ] + + operations = [ + migrations.CreateModel( + name='Example', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('title', models.CharField(max_length=100, verbose_name='title')), + ('document_choose_or_browse', filer.fields.image.FilerImageField(related_name='documents+', validators=[filer.validators.FileMimetypeValidator(['application/msword', 'application/pdf', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.presentation', 'text/csv'])], to='filer.Image', blank=True, help_text='CSV, PDF, ODT, DOC...', null=True, verbose_name='document')), + ('file_choose_only', filer.fields.file.FilerFileField(related_name='files+', verbose_name='file', blank=True, to='filer.File', null=True)), + ('illustration_browse_only', filer.fields.image.FilerImageField(related_name='illustrations+', verbose_name='illustration', blank=True, to='filer.Image', null=True)), + ], + options={ + 'verbose_name': 'example', + 'verbose_name_plural': 'examples', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ExampleGalleryElement', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('order', models.PositiveIntegerField(default=0, verbose_name='order', blank=True)), + ('example', models.ForeignKey(related_name='gallery_elements', verbose_name='example', to='thirdparty_app.Example')), + ('image', filer.fields.image.FilerImageField(related_name='images+', validators=[filer.validators.FileMimetypeValidator(['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif'])], to='filer.Image', blank=True, null=True, verbose_name='image')), + ], + options={ + 'ordering': ('example__pk', 'order'), + 'verbose_name': 'gallery', + 'verbose_name_plural': 'galleries', + }, + bases=(models.Model,), + ), + ] diff --git a/filer/test_utils/thirdparty_app/migrations/__init__.py b/filer/test_utils/thirdparty_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/filer/test_utils/thirdparty_app/models.py b/filer/test_utils/thirdparty_app/models.py new file mode 100644 index 000000000..d6476a5bf --- /dev/null +++ b/filer/test_utils/thirdparty_app/models.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models + +from filer.fields.file import FilerFileField +from filer.fields.image import FilerImageField +from filer.validators import validate_documents, validate_html5_images + + +class Example(models.Model): + title = models.CharField( + verbose_name='title', + max_length=100,) + illustration_browse_only = FilerImageField( + verbose_name='illustration', + default_direct_upload_enabled=True, # add a "browse" button with ajax upload + default_file_lookup_enabled=False, # remove the "choose" link + default_folder_key='IMAGES', + null=True, blank=True, + related_name='illustrations+',) + file_choose_only = FilerFileField( + verbose_name='file', + default_direct_upload_enabled=False, # remove the "browse" button with ajax upload + default_file_lookup_enabled=True, # add a "choose" link + default_folder_key='USER_OWN_FOLDER', + null=True, blank=True, + related_name='files+',) + document_choose_or_browse = FilerFileField( + verbose_name='document', + help_text='CSV, PDF, ODT, DOC...', + default_direct_upload_enabled=True, # add a "browse" button with ajax upload + default_file_lookup_enabled=True, # add a "choose" link + default_folder_key='DOCUMENTS', + validators=[validate_documents, ], + null=True, blank=True, + related_name='documents+',) + + class Meta: + app_label = 'thirdparty_app' + verbose_name = 'example' + verbose_name_plural = 'examples' + + def __str__(self): + return '%s' % self.title + + +class ExampleGalleryElement(models.Model): + order = models.PositiveIntegerField( + verbose_name='order', + blank=True, null=False, + default=0, + ) + image = FilerImageField( + verbose_name='image', + default_direct_upload_enabled=True, # add a "browse" button with ajax upload + default_file_lookup_enabled=False, # remove the "choose" link + default_folder_key='IMAGES', + null=True, blank=True, + validators=[validate_html5_images, ], + related_name='images+',) + example = models.ForeignKey(Example, + verbose_name='example', + related_name='gallery_elements',) + + class Meta: + app_label = 'thirdparty_app' + ordering = ('example__pk', 'order',) + verbose_name = 'gallery' + verbose_name_plural = 'galleries' diff --git a/filer/test_utils/thirdparty_app/south_migrations/0001_initial.py b/filer/test_utils/thirdparty_app/south_migrations/0001_initial.py new file mode 100644 index 000000000..aac838687 --- /dev/null +++ b/filer/test_utils/thirdparty_app/south_migrations/0001_initial.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +from filer.settings import FILER_IMAGE_MODEL + + +class Migration(SchemaMigration): + + if FILER_IMAGE_MODEL: + depends_on = ( + ("filer", "0014_auto__add_field_image_related_url__chg_field_file_name"), + ("custom_image", "0001_initial"), + ) + + else: + depends_on = ( + ("filer", "0014_auto__add_field_image_related_url__chg_field_file_name"), + ) + + + def forwards(self, orm): + # Adding model 'Example' + db.create_table(u'thirdparty_app_example', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('title', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('illustration_browse_only', self.gf(u'django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'illustrations+', null=True, to=orm['filer.Image'])), + ('file_choose_only', self.gf(u'django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'files+', null=True, to=orm['filer.File'])), + ('document_choose_or_browse', self.gf(u'django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'documents+', null=True, to=orm['filer.Image'])), + )) + db.send_create_signal(u'thirdparty_app', ['Example']) + + # Adding model 'ExampleGalleryElement' + db.create_table(u'thirdparty_app_examplegalleryelement', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True)), + ('image', self.gf(u'django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'images+', null=True, to=orm['filer.Image'])), + ('example', self.gf('django.db.models.fields.related.ForeignKey')(related_name=u'gallery_elements', to=orm['thirdparty_app.Example'])), + )) + db.send_create_signal(u'thirdparty_app', ['ExampleGalleryElement']) + + + def backwards(self, orm): + # Deleting model 'Example' + db.delete_table(u'thirdparty_app_example') + + # Deleting model 'ExampleGalleryElement' + db.delete_table(u'thirdparty_app_examplegalleryelement') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'filer.file': { + 'Meta': {'object_name': 'File'}, + '_file_size': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'folder': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'all_files'", 'null': 'True', 'to': u"orm['filer.Folder']"}), + 'has_all_mandatory_data': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'modified_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), + 'original_filename': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'owned_files'", 'null': 'True', 'to': u"orm['auth.User']"}), + 'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'polymorphic_filer.file_set+'", 'null': 'True', 'to': u"orm['contenttypes.ContentType']"}), + 'sha1': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '40', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}) + }, + u'filer.folder': { + 'Meta': {'ordering': "(u'name',)", 'unique_together': "((u'parent', u'name'),)", 'object_name': 'Folder'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + u'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'modified_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'filer_owned_folders'", 'null': 'True', 'to': u"orm['auth.User']"}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'children'", 'null': 'True', 'to': u"orm['filer.Folder']"}), + u'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + u'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}) + }, + 'filer.image': { + 'Meta': {'object_name': 'Image'}, + '_height': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + '_width': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'author': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'date_taken': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'default_alt_text': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'default_caption': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + u'file_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': u"orm['filer.File']", 'unique': 'True', 'primary_key': 'True'}), + 'must_always_publish_author_credit': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'must_always_publish_copyright': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'subject_location': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '64', 'null': 'True', 'blank': 'True'}) + }, + u'thirdparty_app.example': { + 'Meta': {'object_name': 'Example'}, + 'document_choose_or_browse': (u'django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'documents+'", 'null': 'True', 'to': "orm['filer.Image']"}), + 'file_choose_only': (u'django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'files+'", 'null': 'True', 'to': u"orm['filer.File']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'illustration_browse_only': (u'django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'illustrations+'", 'null': 'True', 'to': "orm['filer.Image']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'thirdparty_app.examplegalleryelement': { + 'Meta': {'ordering': "(u'example__pk', u'order')", 'object_name': 'ExampleGalleryElement'}, + 'example': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'gallery_elements'", 'to': u"orm['thirdparty_app.Example']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': (u'django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'images+'", 'null': 'True', 'to': "orm['filer.Image']"}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + } + } + + complete_apps = ['thirdparty_app'] diff --git a/filer/test_utils/thirdparty_app/south_migrations/__init__.py b/filer/test_utils/thirdparty_app/south_migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/filer/tests/__init__.py b/filer/tests/__init__.py index b7935d018..74cecf9ab 100644 --- a/filer/tests/__init__.py +++ b/filer/tests/__init__.py @@ -1,6 +1,7 @@ #-*- coding: utf-8 -*- from filer.tests.admin import * from filer.tests.dump import * +from filer.tests.misc import * from filer.tests.models import * from filer.tests.permissions import * from filer.tests.server_backends import * diff --git a/filer/tests/admin.py b/filer/tests/admin.py index 06fc0f5b8..9bbd69cb0 100644 --- a/filer/tests/admin.py +++ b/filer/tests/admin.py @@ -83,6 +83,12 @@ def test_filer_directory_listing_root_get(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.context['folder'].children.count(), 6) + def test_filer_directory_listing_by_key(self): + response = self.client.get(reverse( + 'admin:filer-directory_listing_by_key', kwargs={'folder_key':'DOCUMENTS'})) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['folder'].name, 'Documents') + def test_validate_no_duplcate_folders(self): FOLDER_NAME = "root folder 1" self.assertEqual(Folder.objects.count(), 0) diff --git a/filer/tests/misc.py b/filer/tests/misc.py new file mode 100644 index 000000000..7e4c15e21 --- /dev/null +++ b/filer/tests/misc.py @@ -0,0 +1,167 @@ +#-*- coding: utf-8 -*- + +import json +from lxml import html +import os + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.files import File as DjangoFile +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.test.client import RequestFactory + +from filer import settings as filer_settings +from filer.models import File, Folder, Image +from filer.tests.helpers import create_superuser, create_image +from filer.utils.folders import DefaultFolderGetter, get_default_folder_getter +from filer.validators import FileMimetypeValidator, validate_documents, validate_images +from filer.test_utils.thirdparty_app.models import Example + + +class FilerTestMixin(object): + def setUp(self): + super(FilerTestMixin, self).setUp() + self.superuser = create_superuser() + self.client.login(username='admin', password='secret') + self.img = create_image() + self.image_name = 'test_file.jpg' + self.filename = os.path.join(settings.FILE_UPLOAD_TEMP_DIR, self.image_name) + self.img.save(self.filename, 'JPEG') + self.usersfolder, self.usersfolder_created = Folder.objects.get_or_create( + name='users_files', parent=None, defaults={'owner': self.superuser,}) + + def tearDown(self): + File.objects.all().delete() + if self.usersfolder_created: + self.usersfolder.delete() + self.client.logout() + + +class FilerDynamicFolderTest(FilerTestMixin, TestCase): + + def test_filer_dynamic_folder_creation(self): + rf = RequestFactory() + request = rf.get("/") + request.session = {} + request.user = self.superuser + folder = get_default_folder_getter().get('USER_OWN_FOLDER', request) + self.assertEqual(folder.name, self.superuser.username) + + def test_filer_dynamic_folder_ajax_upload_file(self): + self.assertEqual(Image.objects.count(), 0) + file_obj = DjangoFile(open(self.filename, 'rb')) + + url = reverse('admin:filer-ajax_upload', kwargs={'folder_key': 'USER_OWN_FOLDER'}) + url += '?qqfile=%s' % (self.image_name, ) + response = self.client.post( + url, data=file_obj.read(), content_type='application/octet-stream', + **{'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} + ) + self.assertEqual(Image.objects.count(), 1) + img = Image.objects.all()[0] + self.assertEqual(img.original_filename, self.image_name) + self.assertEqual(img.folder.name, self.superuser.username) + self.assertEqual(img.folder.parent_id, self.usersfolder.pk) + + +class FilerMimetypeLimitationTest(FilerTestMixin, TestCase): + + def setUp(self): + super(FilerMimetypeLimitationTest, self).setUp() + file_obj = DjangoFile(open(self.filename, 'rb'), name=self.image_name) + self.image = Image.objects.create( + owner=self.superuser, + is_public=True, + original_filename=self.image_name, + file=file_obj + ) + + def test_filer_specific_mimetype_validator(self): + jpeg_validator = FileMimetypeValidator(['image/jpeg',]) + try: + jpeg_validator(self.image.pk) + except ValidationError: + self.failfast("FileMimetypeValidator() raised ValidationError unexpectedly !") + + png_validator = FileMimetypeValidator(['image/png',]) + with self.assertRaises(ValidationError): + png_validator(self.image.pk) + + def test_filer_generic_mimetype_validator(self): + try: + validate_images(self.image.pk) + except ValidationError: + self.failfast("FileMimetypeValidator() raised ValidationError unexpectedly !") + + with self.assertRaises(ValidationError): + validate_documents(self.image.pk) + + def test_filer_partial_ajax_upload_mimetype_validator(self): + self.assertEqual(File.objects.count(), 1) + file_obj = DjangoFile(open(self.filename, 'rb')) + url = reverse('admin:filer-ajax_upload', + kwargs={'related_field': 'thirdparty_app.Example.illustration_browse_only', + 'folder_key': 'no_folder'}) + url += '?qqfile=other.jpeg' + response = self.client.post( + url, data=file_obj.read(), content_type='application/octet-stream', + **{'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(File.objects.count(), 2) + self.assertEqual(Image.objects.count(), 2) + + file_obj.seek(0) + + url = reverse('admin:filer-ajax_upload', + kwargs={'related_field': 'thirdparty_app.Example.document_choose_or_browse', + 'folder_key': 'no_folder'}) + url += '?qqfile=again.jpeg' + response = self.client.post( + url, data=file_obj.read(), content_type='application/octet-stream', + **{'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(File.objects.count(), 2) + data = json.loads(response.content.decode()) + self.assertTrue(bool(data.get('error'))) + + +class FilerWidgetTest(FilerTestMixin, TestCase): + + def test_filer_widget_choose_and_or_browse(self): + url = reverse('admin:thirdparty_app_example_add') + response = self.client.get(url) + dom = html.fromstring(response.content) + ids = [ + el.attrib['id'] for el in dom.xpath('//a[@class="js-related-lookup related-lookup"]')] + expected_choose_ids = [ + 'id_file_choose_only_lookup', 'id_document_choose_or_browse_lookup'] + self.assertEqual(ids, expected_choose_ids) + + ids = [ + el.attrib['id'] for el in dom.xpath('//div[@class="dz-default dz-message '\ + 'js-filer-dropzone-message"]')] + expected_browse_ids = [ + 'id_illustration_browse_only_filer_dropzone_message', + 'id_document_choose_or_browse_filer_dropzone_message'] + self.assertEqual(ids, expected_browse_ids) + + + def test_filer_widget_folder_key(self): + url = reverse('admin:thirdparty_app_example_add') + response = self.client.get(url) + dom = html.fromstring(response.content) + + for field_name in ('file_choose_only', 'document_choose_or_browse'): + field = Example._meta.get_field_by_name(field_name)[0] + folder_key = field.default_formfield_kwargs.get('folder_key') + expected_href = reverse( + 'admin:filer-directory_listing_by_key', kwargs={'folder_key':folder_key}) + try: + href = dom.get_element_by_id('id_%s_lookup' % field_name).attrib['href'] + except (KeyError, IndexError): + self.fail('DOM for "%s" field is not as expected.' % field_name)[0] + href = href.split('?')[0] + self.assertEqual(href, expected_href) \ No newline at end of file diff --git a/filer/utils/compatibility.py b/filer/utils/compatibility.py index a8cd794ac..1306735c5 100644 --- a/filer/utils/compatibility.py +++ b/filer/utils/compatibility.py @@ -4,6 +4,12 @@ import django from django.utils import six +try: + from django.apps import apps + get_model = apps.get_model +except ImportError: + from django.db.models import get_model + try: from django.utils.text import truncate_words except ImportError: diff --git a/filer/utils/files.py b/filer/utils/files.py index abead48b0..9a04ac806 100644 --- a/filer/utils/files.py +++ b/filer/utils/files.py @@ -5,20 +5,38 @@ import os import sys +from django.core.exceptions import ValidationError from django.utils.text import get_valid_filename as get_valid_filename_django from django.template.defaultfilters import slugify as slugify_django from django.http.multipartparser import ( ChunkIter, exhaust, StopFutureHandlers, SkipFile, StopUpload) from unidecode import unidecode +from filer.validators import FileMimetypeValidator + class UploadException(Exception): pass -def handle_upload(request): - if not request.method == "POST": +class ChunkFile(object): + """ + Simple class for mimetype detection for a file's chunk. + FileMimetypeValidator needs an object with `.name` and `.read()` + """ + def __init__(self, name, chunk): + self.name = name + self.chunk = chunk + + def read(self, length): + return self.chunk[0:length] + + +def handle_upload(request, mimetypes=None): + if request.method != "POST": raise UploadException("AJAX request not valid: must be POST") + + re_raise_exception = None if request.is_ajax(): # the file is stored raw in the request is_raw = True @@ -50,6 +68,7 @@ def handle_upload(request): stream = ChunkIter(request, chunk_size) counters = [0] * len(upload_handlers) + mimetype_to_check = bool(mimetypes) try: for handler in upload_handlers: try: @@ -59,6 +78,14 @@ def handle_upload(request): break for chunk in stream: + if mimetype_to_check: + try: + FileMimetypeValidator(mimetypes)(ChunkFile(filename, chunk)) + except ValidationError as e: + re_raise_exception = UploadException(' ; '.join(e.messages)) + raise StopUpload(connection_reset=True) + mimetype_to_check = False + for i, handler in enumerate(upload_handlers): chunk_length = len(chunk) chunk = handler.receive_data_chunk(chunk, @@ -78,6 +105,9 @@ def handle_upload(request): # Make sure that the request data is all fed exhaust(request) + if re_raise_exception: + raise(re_raise_exception) + # Signal that the upload has completed. for handler in upload_handlers: retval = handler.upload_complete() @@ -91,13 +121,13 @@ def handle_upload(request): break else: if len(request.FILES) == 1: - upload, filename, is_raw = handle_request_files_upload(request) + upload, filename, is_raw = handle_request_files_upload(request, mimetypes=None) else: raise UploadException("AJAX request not valid: Bad Upload") return upload, filename, is_raw -def handle_request_files_upload(request): +def handle_request_files_upload(request, mimetypes=None): """ Handle request.FILES if len(request.FILES) == 1. Returns tuple(upload, filename, is_raw) where upload is file itself. @@ -111,6 +141,11 @@ def handle_request_files_upload(request): is_raw = False upload = list(request.FILES.values())[0] filename = upload.name + if mimetypes: + try: + FileMimetypeValidator(mimetypes)(upload) + except ValidationError as e: + raise UploadException(' ; '.join(e.messages)) return upload, filename, is_raw diff --git a/filer/utils/folders.py b/filer/utils/folders.py new file mode 100644 index 000000000..e40174d05 --- /dev/null +++ b/filer/utils/folders.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from filer import settings as filer_settings +from filer.models import Folder +from filer.utils.loader import load_object + + +def get_default_folder_getter(): + path = filer_settings.FILER_DEFAULT_FOLDER_GETTER + if path: + if path == 'filer.utils.folders.DefaultFolderGetter': + return DefaultFolderGetter + return load_object(path) + raise Exception('FILER_DEFAULT_FOLDER_GETTER improperly configured') + + +class DefaultFolderGetter(object): + """ + Default Folder getter to configure some "dynamic" folders + You just have to add a method named as the key attr. exemple : + + @classmethod + def USER_OWN_FOLDER(cls, request): + if not request.user.is_authenticated(): + return None + parent_kwargs = { + name: 'users_files', + + } + return cls._get_or_create(name=user.username, owner=user, parent_kwargs=parent_kwargs) + """ + + @classmethod + def _get_or_create(cls, name, owner=None, parent_kwargs=None): + filters = {'name': name} + if parent_kwargs: + parent_folder, created = Folder.objects.get_or_create(**parent_kwargs) + filters['parent_id'] = parent_folder.pk + else: + filters['parent_id__isnull'] = True + if owner: + filters['owner'] = owner + folder = Folder.objects.filter(**filters)[0:1] + if not folder: + folder = Folder() + folder.name = name + if parent_kwargs: + folder.parent_id = parent_folder.pk + if owner: + folder.owner = owner + folder.save() + else: + folder = folder[0] + return folder + + @classmethod + def get(cls, key, request): + if hasattr(cls, key): + getter = getattr(cls, key) + if callable(getter): + return getter(request) + return None diff --git a/filer/validators.py b/filer/validators.py new file mode 100644 index 000000000..f3e5d0a32 --- /dev/null +++ b/filer/validators.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +try: + from magic import from_buffer + + def get_mime_type(fp): + return from_buffer(fp.read(1024), mime=True) +except ImportError: + import warnings + warnings.warn(( + 'Can not import python-magic. ' + 'Mime detection will be based on file\'s extension : this is not safe at all.' + 'Please install python-magic for better mime type detection based on file\'s content.' + )) + + from mimetypes import guess_type + + def get_mime_type(fp): + (mime, encoding) = guess_type(fp.name, strict=False) + return mime + +try: + from django.utils.deconstruct import deconstructible +except ImportError: + def deconstructible(cls): + return cls + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + + +@deconstructible +class FileMimetypeValidator(object): + """ + Validates that a file has a correct mimetype. + Value can be : + * an integer : pk of the File instance to validate + * a File instance + * a File instance # TODO : File ? + * a ChunkFile File instance (part of a file during the upload process) + raises ValidationError if file's mimetype is not valid + """ + def __init__(self, mimetypes): + self.mimetypes = mimetypes + + def __call__(self, value): + if not value: + return + + if type(value) == int: + from filer.models import File + try: + f = File.objects.get(pk=value) + except File.DoesNotExist: + raise ValidationError(_('This value is not a valid file')) + fp = f.file + elif hasattr(value, 'name') and hasattr(value, 'read') and callable(value.read): + # we only need .name and .read(). If this object have this attr and this method, + # it's ok + fp = value + elif hasattr(value, 'file'): + fp = value.file + else: + raise ValidationError(_('This value is not a valid file')) + + try: + mime = get_mime_type(fp) + except AttributeError: + mime = None + + if not mime: + raise ValidationError(_('This value is not a valid file')) + + wildcard_mime = '%s/*' % mime.split('/')[0] + + if mime not in self.mimetypes and wildcard_mime not in self.mimetypes: + msg = _('%(file)s is not a valid file. Allowed file types are : %(types)s') % { + 'file': fp.name, + 'types': ', '.join(self.mimetypes), + } + raise ValidationError(msg) + + def __eq__(self, other): + return self.mimetypes == other.mimetypes + + +validate_images = FileMimetypeValidator(['image/*', ]) +validate_videos = FileMimetypeValidator(['video/*', ]) +validate_audios = FileMimetypeValidator(['audio/*', ]) + +validate_documents = FileMimetypeValidator([ + # Main document mimetypes for CSV, DOC, XLS, PPT, ODT, ODS, ODP, PDF + 'application/msword', + 'application/pdf', + 'application/vnd.ms-excel', + 'application/vnd.ms-powerpoint', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'text/csv', +]) + +validate_html5_images = FileMimetypeValidator([ + # Main supported mime types for web image content : SVG, JPEG, PNG, GIF + 'image/svg+xml', # http://caniuse.com/#feat=svg + 'image/jpeg', + 'image/png', + 'image/gif' +]) + +validate_html5_videos = FileMimetypeValidator([ + # Main supported mime types for web video content : OGV, MP4, WEBM + 'video/ogg', # http://caniuse.com/#search=ogg + 'video/mp4', # http://caniuse.com/#search=mp4 + 'video/webm', # http://caniuse.com/#search=webm +]) + +validate_html5_audios = FileMimetypeValidator([ + # Main supported mime types for web audio content : AAC, OGG, MP4, MP3, WAV, WEBM + 'audio/aac', + 'audio/ogg', + 'audio/mp4', + 'audio/mpeg', + 'audio/wav', + 'audio/webm', +]) diff --git a/test_settings.py b/test_settings.py index 0bfea56a9..db12de63c 100644 --- a/test_settings.py +++ b/test_settings.py @@ -18,6 +18,7 @@ def gettext(s): 'mptt', 'filer', 'filer.test_utils.test_app', + 'filer.test_utils.thirdparty_app', ], 'LANGUAGE_CODE': 'en', 'LANGUAGES': ( @@ -58,7 +59,7 @@ def gettext(s): 'TEMPLATE_DIRS': (os.path.join(BASE_DIR, 'django-filer', 'filer', 'test_utils', 'templates'),), 'FILER_CANONICAL_URL': 'test-path/', - + 'FILER_DEFAULT_FOLDER_GETTER': 'filer.test_utils.thirdparty_app.handlers.CustomFolderGetter', } if os.environ.get('CUSTOM_IMAGE', False): HELPER_SETTINGS['FILER_IMAGE_MODEL'] = os.environ.get('CUSTOM_IMAGE', False) diff --git a/tox.ini b/tox.ini index f556743e8..c9f5ea1ad 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ envlist = [testenv:docs] changedir = docs deps = + lxml sphinx commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html @@ -21,7 +22,7 @@ commands = flake8 [flake8] ignore = E251,E128,E501 -exclude = build/*,docs/*,filer/migrations/*,filer/south_migrations/*,filer/migrations_django/*,filer/settings.py,filer/tests/*,filer/test_utils/custom_image/*,filer/test_utils/test_app/south_migrations/* +exclude = build/*,docs/*,filer/migrations/*,filer/south_migrations/*,filer/migrations_django/*,filer/settings.py,filer/tests/*,filer/test_utils/custom_image/*,filer/test_utils/test_app/south_migrations/*,filer/test_utils/thirdparty_app/south_migrations/*,filer/test_utils/thirdparty_app/migrations/* max-line-length = 80 [testenv:frontend] @@ -41,6 +42,7 @@ commands = setenv = custom_image: CUSTOM_IMAGE=filer.test_utils.custom_image.models.Image deps = + lxml thumbs1x: easy-thumbnails>=1.4,<2.0 thumbs2x: easy-thumbnails>=2.0,<2.4 django15: -rtest_requirements/django-1.5.txt