commit c9040ef4fc1a8bac8c604aa4cde9761f8685d2f2 Author: Erik Stein Date: Wed Feb 13 12:07:46 2019 +0100 Initial import. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dd8aba --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.py? +*.sw? +*~ +.coverage +.tox +/*.egg-info +build +dist +__pycache__ +_version.py diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..4a52688 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,2 @@ +Parts based on django-cabinet by Feinheit AG +Erik Stein diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..07c538a --- /dev/null +++ b/CHANGES @@ -0,0 +1,2 @@ +0.1 2019-02-04 +- Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a603bba --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2017, Feinheit AG and individual contributors. +Copyright (c) 2018 Erik Stein +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Feinheit AG nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..753841c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include AUTHORS +include LICENSE +include README.md +recursive-include shared/media_archive/templates * diff --git a/README.md b/README.md new file mode 100644 index 0000000..10ce594 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# django-shared-mediaarchive + +Partially based an django-cabinet by Feinheit AG. + +We don't use a fixed directory structured for managing files but a hierarchical structure of categories called "working folders". An uploaded file can be part of multiple categories. Filesystem locations are fully transparent and request paths will be calculated dynamically based on metadata. + +Needs at least Django 1.11. diff --git a/TODO b/TODO new file mode 100644 index 0000000..7b638fd --- /dev/null +++ b/TODO @@ -0,0 +1,2 @@ +- Merge django-assetkit +- Generic extensibility for file metadata diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..76a1c1f --- /dev/null +++ b/setup.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python + +from io import open +from setuptools import setup, find_packages +import os +import re +import subprocess + + +""" +Use `git tag 1.0.0` to tag a release; `python setup.py --version` +to update the _version.py file. +""" + + +def get_version(prefix): + if os.path.exists('.git'): + parts = subprocess.check_output(['git', 'describe', '--tags']).decode().strip().split('-') + if len(parts) == 3: + version = '{}.{}+{}'.format(*parts) + else: + version = parts[0] + version_py = "__version__ = '{}'".format(version) + _version = os.path.join(prefix, '_version.py') + if not os.path.exists(_version) or open(_version).read().strip() != version_py: + with open(_version, 'w') as fd: + fd.write(version_py) + return version + else: + for f in ('_version.py', '__init__.py'): + f = os.path.join(prefix, f) + if os.path.exists(f): + with open(f) as fd: + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fd.read())) + if 'version' in metadata: + break + return metadata['version'] + + +def read(filename): + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, encoding='utf-8') as handle: + return handle.read() + + +setup( + name='django-shared-mediaarchive', + version=get_version('shared/media_archive'), + description=' Django Media Archive Dropin App', + long_description=read('README.md'), + author='Erik Stein', + author_email='erik@classlibrary.net', + url='https://projects.c--y.net/erik/django-shared-mediaarchive/', + license='BSD License', + platforms=['OS Independent'], + packages=find_packages( + exclude=['tests', 'testapp'], + ), + namespace_packages=['shared'], + include_package_data=True, + install_requires=[ + # 'django>=1.11', commented out to make `pip install -U` easier + 'django-shared-utils', + ], + dependency_links=[ + 'git+https://github.com/sha-red/django-content-plugins.git#egg=django-content-plugins', + 'git+https://github.com/sha-red/django-shared-utils.git#egg=django-shared-utils', + ], + classifiers=[ + # "Development Status :: 3 - Alpha", + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Utilities', + ], + zip_safe=False, +) diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..de40ea7 --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/shared/media_archive/__init__.py b/shared/media_archive/__init__.py new file mode 100644 index 0000000..01f4b48 --- /dev/null +++ b/shared/media_archive/__init__.py @@ -0,0 +1,10 @@ +try: + from ._version import __version__ +except ImportError: + __version__ = '0.1' + +VERSION = __version__.split('+') +VERSION = tuple(list(map(int, VERSION[0].split('.'))) + VERSION[1:]) + + +default_app_config = 'shared.media_archive.apps.MediaArchiveConfig' diff --git a/shared/media_archive/admin.py b/shared/media_archive/admin.py new file mode 100644 index 0000000..987cf9b --- /dev/null +++ b/shared/media_archive/admin.py @@ -0,0 +1,237 @@ +from django import VERSION as DJANGO_VERSION +from django.contrib import admin +from django.contrib.admin.filters import ChoicesFieldListFilter, FieldListFilter +from django.db.models import Count +from django.utils.encoding import smart_text +from django.utils.html import format_html, mark_safe +from django.utils.translation import gettext_lazy as _ + +from imagekit.admin import AdminThumbnail + +from .conf import USE_TRANSLATABLE_FIELDS +from .forms import MediaCategoryAdminForm +from . import admin_actions, mixins, models + +if USE_TRANSLATABLE_FIELDS: + from shared.multilingual.utils import i18n_fields, lang_suffix +else: + def i18n_fields(field_name, languages=None): + return [field_name] + + def lang_suffix(language_code=None, fieldname=""): + return fieldname + + +class CategoryFieldListFilter(ChoicesFieldListFilter): + """ + Customization of ChoicesFilterSpec which sorts in the user-expected format. + + my_model_field.category_filter = True + """ + + template = "media_archive/admin/select_filter.html" + + def __init__(self, f, request, params, model, model_admin, + field_path=None): + super(CategoryFieldListFilter, self).__init__( + f, request, params, model, model_admin, field_path) + + # Restrict results to categories which are actually in use: + if DJANGO_VERSION < (1, 8): + related_model = f.related.parent_model + related_name = f.related.var_name + elif DJANGO_VERSION < (2, 0): + related_model = f.rel.to + related_name = f.related_query_name() + else: + related_model = f.remote_field.model + related_name = f.related_query_name() + + self.lookup_choices = sorted( + [ + (i.pk, '%s (%s)' % (i, i._related_count)) + for i in related_model.objects.annotate( + _related_count=Count(related_name) + ).exclude(_related_count=0) + ], + key=lambda i: i[1], + ) + + def choices(self, cl): + yield { + 'selected': self.lookup_val is None, + 'query_string': cl.get_query_string({}, [self.lookup_kwarg]), + 'display': _('All') + } + + for pk, title in self.lookup_choices: + yield { + 'selected': pk == int(self.lookup_val or '0'), + 'query_string': cl.get_query_string({self.lookup_kwarg: pk}), + 'display': mark_safe(smart_text(title)) + } + + +FieldListFilter.register( + lambda f: getattr(f, 'category_filter', False), + CategoryFieldListFilter, + take_priority=True) + + +@admin.register(models.MediaCategory) +class MediaCategoryAdmin(admin.ModelAdmin): + form = MediaCategoryAdminForm + list_display = ['path'] + list_filter = ['parent'] + list_per_page = 25 + search_fields = ['name'] + prepopulated_fields = {'slug': ('name',)} + + raw_id_fields = ['parent'] + + +def image_thumbnail_image(obj): + # TODO Return default placeholder image instead of None + return obj.image.thumbnail + + +@admin.register(models.MediaRole) +class MediaRoleAdmin(admin.ModelAdmin): + list_display = i18n_fields('name') + + +class MediaAdminBase(admin_actions.MediaBaseActionsMixin, mixins.DropUploadAdminMixin, admin.ModelAdmin): + list_display = ['is_public', 'admin_thumbnail', 'get_name_display', + 'get_categories_display', + 'modified'] # , 'created'] + list_display_links = ('admin_thumbnail', 'get_name_display') + # list_editable = ['is_public'] + list_per_page = 25 + list_filter = ['is_public', 'categories', 'role'] + search_fields = [ + *i18n_fields('name'), + 'slug', + *i18n_fields('caption'), + *i18n_fields('credits'), + *i18n_fields('copyright'), + ] + date_hierarchy = 'modified' + ordering = ['-modified'] + + fieldsets = ( + (None, { + 'fields': [ + 'is_public', + 'file', + *i18n_fields('name'), + 'role', + ]}), + (_("Texte"), { + 'fields': [ + *i18n_fields('caption'), + *i18n_fields('credits'), + *i18n_fields('copyright'), + ]}), + (_("Ordnung"), { + 'fields': [ + 'categories', + ]}), + ) + filter_horizontal = ['categories'] + + admin_thumbnail = AdminThumbnail( + image_field='thumbnail', + template='imagekit/admin/selectable_thumbnail.html') + admin_thumbnail.short_description = _("Foto") + + # TODO class Media: add switch_languages script + + def get_name_display(self, obj): + return format_html( + "{categories}
{caption}", + categories=", ".join([str(p) for p in obj.categories.all()]), + caption=str(obj), + ) + get_name_display.short_description = _("Name") + get_name_display.admin_order_field = lang_suffix(fieldname='name') + + def get_categories_display(self, obj): + return mark_safe("
".join([str(c) for c in obj.categories.all()])) + get_categories_display.short_description = _("Arbeitsmappen") + get_categories_display.admin_order_field = 'categories__name' + + +@admin.register(models.Image) +class ImageAdmin(MediaAdminBase): + actions = [ + admin_actions.assign_category, + 'change_is_public_action', + admin_actions.add_images_to_gallery, + ] + + +@admin.register(models.Download) +class DownloadAdmin(MediaAdminBase): + list_display = ['is_public', '__str__'] + list_display_links = ['__str__'] + actions = [ + admin_actions.assign_category, + 'change_is_public_action', + ] + + +class ImageGalleryRelInline(admin.TabularInline): + model = models.ImageGalleryRel + fields = ['admin_thumbnail', 'image', 'position'] + readonly_fields = ['admin_thumbnail'] + raw_id_fields = ['image'] + extra = 0 + verbose_name = _("Bild") + verbose_name_plural = _("Bilder") + + admin_thumbnail = AdminThumbnail( + image_field=image_thumbnail_image, + template='imagekit/admin/selectable_thumbnail.html') + admin_thumbnail.short_description = _("Foto") + + +@admin.register(models.Gallery) +class GalleryAdmin(admin.ModelAdmin): + list_display = ('is_public', 'name', 'get_image_count') + list_display_links = ['name'] + list_filter = ['is_public'] + search_fields = [ + *i18n_fields('name'), + 'slug', + *i18n_fields('caption'), + *i18n_fields('credits'), + ] + + fieldsets = ( + (None, { + 'fields': [ + 'is_public', + *i18n_fields('name'), + 'slug', + ]}), + (_("Texte"), { + 'fields': [ + *i18n_fields('caption'), + *i18n_fields('credits'), + ]}), + (_("Weiteres"), { + 'classes': ['collapse'], + 'fields': [ + 'order_index', + ]}), + ) + prepopulated_fields = { + 'slug': ['name'], + } + + inlines = [ImageGalleryRelInline] + + def get_image_count(self, obj): + return obj.images.count() + get_image_count.short_description = _("Bilder") + diff --git a/shared/media_archive/admin_actions.py b/shared/media_archive/admin_actions.py new file mode 100644 index 0000000..333a8a2 --- /dev/null +++ b/shared/media_archive/admin_actions.py @@ -0,0 +1,98 @@ +from django import forms +from django.contrib import admin +from django.http import HttpResponseRedirect +from django.utils.translation import ngettext, gettext_lazy as _ +from django.shortcuts import render + +from shared.utils.admin_actions import AdminActionBase, TargetActionBase +from . import models + + +class AddImagesToGalleryAction(TargetActionBase): + target_model = models.Gallery + related_field_name = 'gallery_set' + + __name__ = title = _("Add images to gallery") + queryset_action_label = _("The selected images will be added to the following gallery:") + action_button_label = _("Add Images") + + def apply(self, queryset, form): + gallery = self.get_target(form) + count = 0 + for image in queryset: + _, created = \ + models.ImageGalleryRel.objects.get_or_create( + image=image, + gallery=gallery) + if created: + count += 1 + return count + + +add_images_to_gallery = AddImagesToGalleryAction('add_images_to_gallery') + + +class AssignCategoryAction(TargetActionBase): + target_model = models.MediaCategory + related_field_name = 'categories' + + __name__ = title = _("Assign category to images") + short_description = title + queryset_action_label = _("Images which will be assigned to the chosen category:") + action_button_label = _("Add Images") + + def apply(self, queryset, form): + category = self.get_target(form) + count = 0 + for mediafile in queryset: + getattr(mediafile, self.related_field_name).add(category) + count += 1 + return count + + +assign_category = AssignCategoryAction('assign_category') + + +class MediaBaseActionsMixin: + def change_is_public_action(self, request, queryset): + modeladmin = self + options_template_name = 'media_archive/admin/action_forms/change_is_public.html' + + class AccessAllowedForm(forms.Form): + _selected_action = forms.CharField(widget=forms.MultipleHiddenInput) + # is_public = forms.BooleanField(label=_("Öffentlich sichtbar")) + is_public = forms.TypedChoiceField( + coerce=lambda x: x == 'True', + choices=((False, _("Nicht veröffentlicht")), (True, _("Veröffentlicht"))), + widget=forms.RadioSelect + ) + + form = None + if 'apply' in request.POST: + form = AccessAllowedForm(request.POST) + if form.is_valid(): + chosen_is_public = form.cleaned_data['is_public'] + count = queryset.update(is_public=chosen_is_public) + message = ngettext( + 'Successfully set %(count)d media file to %(chosen_is_public)s.', + 'Successfully set %(count)d media files to %(chosen_is_public)s.', + count) % {'count': count, 'chosen_is_public': chosen_is_public} + modeladmin.message_user(request, message) + return HttpResponseRedirect(request.get_full_path()) + if 'cancel' in request.POST: + return HttpResponseRedirect(request.get_full_path()) + + if not form: + form = AccessAllowedForm(initial={ + '_selected_action': request.POST.getlist( + admin.ACTION_CHECKBOX_NAME), + }) + + return render(request, options_template_name, context={ + 'mediafiles': queryset, + 'action_form': form, + 'opts': modeladmin.model._meta, + 'queryset': queryset, + }) + change_is_public_action.short_description = _("Zugriff für ausgewählte Mediendateien setzen") + diff --git a/shared/media_archive/apps.py b/shared/media_archive/apps.py new file mode 100644 index 0000000..a58857b --- /dev/null +++ b/shared/media_archive/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class MediaArchiveConfig(AppConfig): + name = 'shared.media_archive' + verbose_name = _("Digital Media File Archive") diff --git a/shared/media_archive/conf.py b/shared/media_archive/conf.py new file mode 100644 index 0000000..94492e7 --- /dev/null +++ b/shared/media_archive/conf.py @@ -0,0 +1,6 @@ +from django.conf import settings + +USE_TRANSLATABLE_FIELDS = ( + getattr(settings, 'CONTENT_PLUGINS_USE_TRANSLATABLE_FIELDS', False) or + getattr(settings, 'USE_TRANSLATABLE_FIELDS', False) +) diff --git a/shared/media_archive/forms.py b/shared/media_archive/forms.py new file mode 100644 index 0000000..3df0478 --- /dev/null +++ b/shared/media_archive/forms.py @@ -0,0 +1,66 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from . import models + + +class MediaCategoryAdminForm(forms.ModelForm): + class Meta: + model = models.MediaCategory + fields = '__all__' + + def clean_parent(self): + data = self.cleaned_data['parent'] + if data is not None and self.instance in data.path_list(): + raise forms.ValidationError( + _("This would create a loop in the hierarchy")) + return data + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['parent'].queryset =\ + self.fields['parent'].queryset.exclude(pk=self.instance.pk) + + +# TODO +# class MediaFileAdminForm(forms.ModelForm): +# class Meta: +# model = MediaFile +# widgets = {'file': AdminFileWithPreviewWidget} +# fields = '__all__' + +# def __init__(self, *args, **kwargs): +# super(MediaFileAdminForm, self).__init__(*args, **kwargs) + +# if settings.FEINCMS_MEDIAFILE_OVERWRITE and self.instance.id: +# field = self.instance.file.field +# if not hasattr(field, '_feincms_generate_filename_patched'): +# original_generate = field.generate_filename + +# def _gen_fname(instance, filename): +# if instance.id and hasattr(instance, 'original_name'): +# logger.info("Overwriting file %s with new data" % ( +# instance.original_name)) +# instance.file.storage.delete(instance.original_name) +# return instance.original_name + +# return original_generate(instance, filename) + +# field.generate_filename = _gen_fname +# field._feincms_generate_filename_patched = True + +# def clean_file(self): +# if settings.FEINCMS_MEDIAFILE_OVERWRITE and self.instance.id: +# new_base, new_ext = os.path.splitext( +# self.cleaned_data['file'].name) +# old_base, old_ext = os.path.splitext(self.instance.file.name) + +# if new_ext.lower() != old_ext.lower(): +# raise forms.ValidationError(_( +# "Cannot overwrite with different file type (attempt to" +# " overwrite a %(old_ext)s with a %(new_ext)s)" +# ) % {'old_ext': old_ext, 'new_ext': new_ext}) + +# self.instance.original_name = self.instance.file.name + +# return self.cleaned_data['file'] diff --git a/shared/media_archive/migrations/0001_initial.py b/shared/media_archive/migrations/0001_initial.py new file mode 100644 index 0000000..827d978 --- /dev/null +++ b/shared/media_archive/migrations/0001_initial.py @@ -0,0 +1,150 @@ +# Generated by Django 2.1.5 on 2019-02-11 16:21 + +from django.db import migrations, models +import django.db.models.deletion +import feincms3.cleanse +import imagefield.fields +import shared.media_archive.models +import shared.utils.models.slugs + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Download', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('image', 'Image'), ('video', 'Video'), ('audio', 'Audio'), ('pdf', 'PDF document'), ('swf', 'Flash'), ('txt', 'Text'), ('rtf', 'Rich Text'), ('zip', 'Zip archive'), ('doc', 'Microsoft Word'), ('xls', 'Microsoft Excel'), ('ppt', 'Microsoft PowerPoint'), ('other', 'Binary')], editable=False, max_length=12, verbose_name='file type')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Hochgeladen')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Geändert')), + ('is_public', models.BooleanField(default=True, help_text='Nur als "öffentlich sichtbar" markierte Mediendaten werden öffentlich angezeigt.', verbose_name='Veröffentlicht')), + ('file_size', models.IntegerField(blank=True, editable=False, null=True, verbose_name='file size')), + ('slug', shared.utils.models.slugs.DowngradingSlugField(blank=True, help_text='Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.')), + ('name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Name')), + ('caption', feincms3.cleanse.CleansedRichTextField(blank=True, verbose_name='Bildunterschrift')), + ('credits', models.CharField(blank=True, max_length=500, null=True, verbose_name='Credits')), + ('copyright', models.CharField(blank=True, max_length=2000, verbose_name='Rechteinhaber/in')), + ('file', models.FileField(upload_to='', verbose_name='Datei')), + ], + options={ + 'verbose_name': 'Download', + 'verbose_name_plural': 'Downloads', + 'ordering': ['name'], + }, + bases=(shared.media_archive.models.DeleteOldFileMixin, models.Model), + ), + migrations.CreateModel( + name='Gallery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('internal_name', models.CharField(help_text='Internal use only, not publicly visible.', max_length=500, verbose_name='Internal Name')), + ('name', models.CharField(blank=True, help_text='Publicly visible name.', max_length=200, null=True, verbose_name='Name')), + ('slug', models.SlugField(blank=True, null=True, verbose_name='Slug')), + ('credits', models.CharField(blank=True, max_length=500, null=True, verbose_name='Credits')), + ('caption', feincms3.cleanse.CleansedRichTextField(blank=True, null=True, verbose_name='Caption')), + ('is_public', models.BooleanField(default=False, verbose_name='Active')), + ('order_index', models.PositiveIntegerField(default=0, verbose_name='Order Index')), + ], + options={ + 'verbose_name': 'Image Gallery', + 'verbose_name_plural': 'Image Galleries', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Hochgeladen')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Geändert')), + ('is_public', models.BooleanField(default=True, help_text='Nur als "öffentlich sichtbar" markierte Mediendaten werden öffentlich angezeigt.', verbose_name='Veröffentlicht')), + ('file_size', models.IntegerField(blank=True, editable=False, null=True, verbose_name='file size')), + ('slug', shared.utils.models.slugs.DowngradingSlugField(blank=True, help_text='Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.')), + ('name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Name')), + ('caption', feincms3.cleanse.CleansedRichTextField(blank=True, verbose_name='Bildunterschrift')), + ('credits', models.CharField(blank=True, max_length=500, null=True, verbose_name='Credits')), + ('copyright', models.CharField(blank=True, max_length=2000, verbose_name='Rechteinhaber/in')), + ('image_width', models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='image width')), + ('image_height', models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='image height')), + ('image_ppoi', imagefield.fields.PPOIField(default='0.5x0.5', max_length=20, verbose_name='primary point of interest')), + ('file', imagefield.fields.ImageField(blank=True, height_field='image_height', upload_to='', verbose_name='image', width_field='image_width')), + ], + options={ + 'verbose_name': 'Bild', + 'verbose_name_plural': 'Bilder', + 'ordering': ['imagegalleryrel__position'], + }, + bases=(shared.media_archive.models.DeleteOldFileMixin, models.Model), + ), + migrations.CreateModel( + name='ImageGalleryRel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveIntegerField(default=0)), + ('gallery', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='media_archive.Gallery')), + ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='media_archive.Image')), + ], + options={ + 'verbose_name': 'Bild', + 'verbose_name_plural': 'Bilder', + 'ordering': ['position'], + }, + ), + migrations.CreateModel( + name='MediaCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='name')), + ('slug', models.SlugField(max_length=150, verbose_name='slug')), + ('parent', models.ForeignKey(blank=True, limit_choices_to={'parent__isnull': True}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='media_archive.MediaCategory', verbose_name='Übergeordnet')), + ], + options={ + 'verbose_name': 'Working Folder', + 'verbose_name_plural': 'Working Folders', + 'ordering': ['parent__name', 'name'], + }, + ), + migrations.CreateModel( + name='MediaRole', + fields=[ + ('id_text', models.CharField(help_text='Dieser Wert wird in der Programmierung benutzt und darf nicht verändert werden.', max_length=20, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200, verbose_name='name')), + ], + options={ + 'verbose_name': 'Bild-Typ', + 'verbose_name_plural': 'Bild-Typen', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='image', + name='categories', + field=models.ManyToManyField(blank=True, to='media_archive.MediaCategory', verbose_name='Arbeitsmappe'), + ), + migrations.AddField( + model_name='image', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='media_archive.MediaRole', verbose_name='Typ'), + ), + migrations.AddField( + model_name='gallery', + name='images', + field=models.ManyToManyField(blank=True, through='media_archive.ImageGalleryRel', to='media_archive.Image', verbose_name='Images'), + ), + migrations.AddField( + model_name='download', + name='categories', + field=models.ManyToManyField(blank=True, to='media_archive.MediaCategory', verbose_name='Arbeitsmappe'), + ), + migrations.AddField( + model_name='download', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='media_archive.MediaRole', verbose_name='Typ'), + ), + ] diff --git a/shared/media_archive/migrations/__init__.py b/shared/media_archive/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/media_archive/mixins.py b/shared/media_archive/mixins.py new file mode 100644 index 0000000..2d93262 --- /dev/null +++ b/shared/media_archive/mixins.py @@ -0,0 +1,67 @@ +from django.http import JsonResponse +from django.core.exceptions import PermissionDenied + + +class DropUploadAdminMixin: + class Media: + css = {"all": ("media_archive/css/admin_file_drop.css",)} + js = ( + "media_archive/js/admin_file_drop.js", + ) + + def get_urls(self): + from django.conf.urls import url + + return [ + url( + r"^upload/$", + self.admin_site.admin_view(self.upload), + name="media_archive_upload", + ) + ] + super().get_urls() + + def upload(self, request): + # We must initialize a fake-ChangeList to be able to get the currently + # selected categories. + # Code copied from django.contrib.admin.options.ModelAdmin.changelist_view + if not self.has_change_permission(request, None): + raise PermissionDenied + + list_display = self.get_list_display(request) + list_display_links = self.get_list_display_links(request, list_display) + list_filter = self.get_list_filter(request) + search_fields = self.get_search_fields(request) + list_select_related = self.get_list_select_related(request) + + # Check actions to see if any are available on this changelist + actions = self.get_actions(request) + if actions: + # Add the action checkboxes if there are any actions available. + list_display = ['action_checkbox'] + list(list_display) + + ChangeList = self.get_changelist(request) + changelist = ChangeList( + request, self.model, list_display, + list_display_links, list_filter, self.date_hierarchy, + search_fields, list_select_related, self.list_per_page, + self.list_max_show_all, self.list_editable, self, + ) + + # Put uploaded file in selected categories + if changelist.result_list: + filtered_categories = changelist.result_list.first().categories.all() + elif 'categories__exact' in changelist.params: + from .models import MediaCategory + filtered_categories = MediaCategory.objects.filter( + categories__exact=int(changelist.params['categories__exact'])) + else: + filtered_categories = None + + f = self.model() + f.file = request.FILES["file"] + f.save() + + if filtered_categories: + for cat in filtered_categories: + f.categories.add(cat) + return JsonResponse({"success": True}) diff --git a/shared/media_archive/models.py b/shared/media_archive/models.py new file mode 100644 index 0000000..874bc78 --- /dev/null +++ b/shared/media_archive/models.py @@ -0,0 +1,330 @@ +import logging +import posixpath +import re + +from django.db import models +from django.utils.html import strip_tags +from django.utils.translation import ugettext_lazy as _ + +from imagefield.fields import ImageField, PPOIField +from imagekit.models import ImageSpecField +from imagekit.processors import Adjust, Thumbnail, ResizeToFit +from shared.utils.models.slugs import DowngradingSlugField, slugify + +from .conf import USE_TRANSLATABLE_FIELDS + +if USE_TRANSLATABLE_FIELDS: + from content_plugins.fields import TranslatableCleansedRichTextField + from shared.multilingual.utils.fields import TranslatableCharField + from shared.multilingual.utils import i18n_fields +else: + TranslatableCharField = models.CharField + from content_plugins.fields import TranslatableCleansedRichTextField + + def i18n_fields(field_name, languages=None): + return [field_name] + + +logger = logging.getLogger(__name__) + + +class MediaCategoryManager(models.Manager): + def get_queryset(self): + return super().get_queryset().select_related("parent") + + +class MediaCategory(models.Model): + name = models.CharField(_("name"), max_length=200) + parent = models.ForeignKey( + 'self', blank=True, null=True, + on_delete=models.CASCADE, + related_name='children', limit_choices_to={'parent__isnull': True}, + verbose_name=_("Übergeordnet")) + slug = models.SlugField(_('slug'), max_length=150) + + objects = MediaCategoryManager() + + class Meta: + verbose_name = _("Working Folder") + verbose_name_plural = _("Working Folders") + ordering = ['parent__name', 'name'] + + def __str__(self): + if self.parent_id: + return '%s - %s' % (self.parent.name, self.name) + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + save.alters_data = True + + def path_list(self): + if self.parent is None: + return [self] + p = self.parent.path_list() + p.append(self) + return p + + def path(self): + return ' - '.join((f.name for f in self.path_list())) + + +class Gallery(models.Model): + internal_name = models.CharField(_("Internal Name"), max_length=500, + help_text=_("Internal use only, not publicly visible.")) + name = TranslatableCharField(_("Name"), max_length=200, null=True, blank=True, + help_text=_("Publicly visible name.")) + slug = models.SlugField(_("Slug"), null=True, blank=True) + credits = TranslatableCharField(_("Credits"), null=True, blank=True, max_length=500) + caption = TranslatableCleansedRichTextField(_("Caption"), null=True, blank=True) + is_public = models.BooleanField(_("Active"), default=False) + order_index = models.PositiveIntegerField(_("Order Index"), default=0) + # background_color = models.ForeignKey('site_pages.Color', on_delete=models.PROTECT, + # null=True, blank=True, + # verbose_name=_("Background color"), + # help_text=_("The background color is used until the first image is loaded.")) + images = models.ManyToManyField('Image', blank=True, + verbose_name=_("Images"), + through='ImageGalleryRel') + + class Meta: + verbose_name = _("Image Gallery") + verbose_name_plural = _("Image Galleries") + ordering = i18n_fields('name') + + def __str__(self): + return self.internal_name or self.name or self.slug + + def public_images(self): + return self.images.filter(is_public=True) + + +def filename_to_slug(instance, field): + n, e = posixpath.splitext(instance.file.name) + return slugify(n) + + +class FileTypeMixin(models.Model): + type = models.CharField(_('file type'), + max_length=12, editable=False, choices=()) + + filetypes = [] + filetypes_dict = {} + + class Meta: + abstract = True + + @classmethod + def register_filetypes(cls, *types): + cls.filetypes[0:0] = types + choices = [t[0:2] for t in cls.filetypes] + cls.filetypes_dict = dict(choices) + cls._meta.get_field('type').choices[:] = choices + + def determine_file_type(self, name): + for type_key, type_name, type_test in self.filetypes: + if type_test(name): + return type_key + return self.filetypes[-1][0] + + def save(self, *args, **kwargs): + self.type = self.determine_file_type(self.file.name) + super().save(*args, **kwargs) + save.alters_data = True + + +class DeleteOldFileMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.file: + self._original_file_name = self.file.name + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # User uploaded a new file. Try to get rid of the old file in + # storage, to avoid having orphaned files hanging around. + if getattr(self, '_original_file_name', None): + if self.file.name != self._original_file_name: + self.delete_mediafile(self._original_file_name) + + def delete_mediafile(self, name=None): + if name is None: + name = self.file.name + try: + self.file.storage.delete(name) + except Exception as e: + logger.warn("Cannot delete media file %s: %s" % (name, e)) + + +class MediaRole(models.Model): + id_text = models.CharField(primary_key=True, max_length=20, + help_text=_("Dieser Wert wird in der Programmierung benutzt und darf nicht verändert werden.")) + name = TranslatableCharField(_("name"), max_length=200) + + class Meta: + verbose_name = _("Bild-Typ") + verbose_name_plural = _("Bild-Typen") + ordering = i18n_fields('name') + + def __str__(self): + return self.name + + +class MediaBaseManager(models.Manager): + def public_objects(self): + return self.get_queryset().filter(is_public=True) + + +class MediaBase(DeleteOldFileMixin, models.Model): + created = models.DateTimeField(_("Hochgeladen"), auto_now_add=True) + modified = models.DateTimeField(_("Geändert"), auto_now=True) + is_public = models.BooleanField(_("Veröffentlicht"), default=True, + help_text=_("Nur als \"öffentlich sichtbar\" markierte Mediendaten werden öffentlich angezeigt.")) + + # file = models.FileField(_("Datei")) + file_size = models.IntegerField(_("file size"), + blank=True, null=True, editable=False) + slug = DowngradingSlugField(blank=True, + populate_from=filename_to_slug, unique_slug=True) + + role = models.ForeignKey(MediaRole, on_delete=models.PROTECT, + verbose_name=_("Typ"), + null=True, blank=True) + name = TranslatableCharField(_("Name"), max_length=200, null=True, blank=True) + caption = TranslatableCleansedRichTextField(_("Bildunterschrift"), blank=True) + credits = TranslatableCharField(_("Credits"), max_length=500, null=True, blank=True) + copyright = TranslatableCharField(_('Rechteinhaber/in'), max_length=2000, blank=True) + + categories = models.ManyToManyField(MediaCategory, + verbose_name=_("Arbeitsmappe"), blank=True) + categories.category_filter = True + + objects = MediaBaseManager() + + class Meta: + abstract = True + + def __str__(self): + return self.name or strip_tags(self.caption) or posixpath.basename(self.file.name) + + def save(self, *args, **kwargs): + if self.file: + try: + self.file_size = self.file.size + except (OSError, IOError, ValueError) as e: + logger.error("Unable to read file size for %s: %s" % (self, e)) + super().save(*args, **kwargs) + save.alters_data = True + + +class Image(MediaBase): + image_width = models.PositiveIntegerField( + _("image width"), blank=True, null=True, editable=False + ) + image_height = models.PositiveIntegerField( + _("image height"), blank=True, null=True, editable=False + ) + image_ppoi = PPOIField(_("primary point of interest")) + # file = models.ImageField(_("Datei")) + file = ImageField( + _("image"), + # upload_to=UPLOAD_TO, + width_field="image_width", + height_field="image_height", + ppoi_field="image_ppoi", + blank=True, + ) + + thumbnail = ImageSpecField(source='file', + processors=[Adjust(contrast=1.2, sharpness=1.1), + Thumbnail(100, 50)], + format='JPEG', options={'quality': 90}) + + gallery_image = ImageSpecField(source='file', + processors=[ResizeToFit(800, 800)], + format='JPEG', options={'quality': 90}) + + lightbox_image = ImageSpecField(source='file', + processors=[ResizeToFit(1600, 1600)], + format='JPEG', options={'quality': 90}) + + gallery_image_thumbnail = ImageSpecField(source='file', + processors=[ + Adjust(contrast=1.2, sharpness=1.1), + # ResizeToFit(180, 120) + ResizeToFit(220, 155) + ], + format='JPEG', options={'quality': 90}) + + type = 'image' + + class Meta: + verbose_name = _("Bild") + verbose_name_plural = _("Bilder") + ordering = ['imagegalleryrel__position'] + + # + # Accessors to GIF images + # FIXME ImageKit should leave alone GIF images in the first place + # TODO Need more robust method to get image type + + def gif_gallery_image_thumbnail(self, image_spec_name='gallery_image_thumbnail'): + # Return gif image URLs without converting. + name, ext = posixpath.splitext(self.file.name) + if ext == '.gif': + return self.file + else: + return getattr(self, image_spec_name) + + def gif_lightbox_image(self): + return self.gif_gallery_image_thumbnail(image_spec_name='lightbox_image') + + +class ImageGalleryRel(models.Model): + image = models.ForeignKey(Image, on_delete=models.CASCADE) + gallery = models.ForeignKey(Gallery, models.CASCADE) + position = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name = _("Bild") + verbose_name_plural = _("Bilder") + ordering = ['position'] + + +class Download(FileTypeMixin, MediaBase): + file = models.FileField(_("Datei")) + + class Meta: + verbose_name = _("Download") + verbose_name_plural = _("Downloads") + ordering = i18n_fields('name') + + def get_display_name(self): + return self.name or posixpath.basename(self.file.name) + + +Download.register_filetypes( + # Should we be using imghdr.what instead of extension guessing? + ('image', _('Image'), lambda f: re.compile( + r'\.(bmp|jpe?g|jp2|jxr|gif|png|tiff?)$', re.IGNORECASE).search(f)), + ('video', _('Video'), lambda f: re.compile( + r'\.(mov|m[14]v|mp4|avi|mpe?g|qt|ogv|wmv|flv)$', + re.IGNORECASE).search(f)), + ('audio', _('Audio'), lambda f: re.compile( + r'\.(au|mp3|m4a|wma|oga|ram|wav)$', re.IGNORECASE).search(f)), + ('pdf', _('PDF document'), lambda f: f.lower().endswith('.pdf')), + ('swf', _('Flash'), lambda f: f.lower().endswith('.swf')), + ('txt', _('Text'), lambda f: f.lower().endswith('.txt')), + ('rtf', _('Rich Text'), lambda f: f.lower().endswith('.rtf')), + ('zip', _('Zip archive'), lambda f: f.lower().endswith('.zip')), + ('doc', _('Microsoft Word'), lambda f: re.compile( + r'\.docx?$', re.IGNORECASE).search(f)), + ('xls', _('Microsoft Excel'), lambda f: re.compile( + r'\.xlsx?$', re.IGNORECASE).search(f)), + ('ppt', _('Microsoft PowerPoint'), lambda f: re.compile( + r'\.pptx?$', re.IGNORECASE).search(f)), + ('other', _('Binary'), lambda f: True), # Must be last +) diff --git a/shared/media_archive/static/media_archive/css/admin_file_drop.css b/shared/media_archive/static/media_archive/css/admin_file_drop.css new file mode 100644 index 0000000..2d72569 --- /dev/null +++ b/shared/media_archive/static/media_archive/css/admin_file_drop.css @@ -0,0 +1,32 @@ +.results { + position: relative; +} +.results > .progress, +.results.dragover::after { + position: absolute; + z-index: 1; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + margin: 15px; + box-shadow: 0 0 0 15px rgba(240, 240, 240, 0.9); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + border: 2px dashed black; + background: rgba(240, 240, 240, 0.9); + pointer-events: none; + font-size: 2rem; +} + +.results.dragover::after { + content: "Drop here to upload"; +} + +.admin-upload-hint { + text-align: center; + margin: 0.3rem 0; +} diff --git a/shared/media_archive/static/media_archive/js/admin_file_drop.js b/shared/media_archive/static/media_archive/js/admin_file_drop.js new file mode 100644 index 0000000..46d7514 --- /dev/null +++ b/shared/media_archive/static/media_archive/js/admin_file_drop.js @@ -0,0 +1,63 @@ +django.jQuery(function($) { + if (!document.body.classList.contains('change-list')) + return; + + var dragCounter = 0, + results = $('.results'); + + results.on('drag dragstart dragend dragover dragenter dragleave drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + console.log(e); + }).on('dragover dragenter', function(e) { + ++dragCounter; + results.addClass('dragover'); + }).on('dragleave dragend', function(e) { + if (--dragCounter <= 0) + results.removeClass('dragover'); + }).on('mouseleave mouseout drop', function(e) { + dragCounter = 0; + results.removeClass('dragover'); + }).on('drop', function(e) { + dragCounter = 0; + results.removeClass('dragover'); + + var files = e.originalEvent.dataTransfer.files, + success = 0, + progress = $('
0 / ' + files.length + '
'); + + progress.appendTo(results); + + for (var i=0; i= files.length) { + window.location.reload(); + } + }, + xhr: function() { + var xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('progress', function(e) { + if (e.lengthComputable) { + progress.html( + Math.round(e.loaded / e.total * 100) + '% of ' + + (success + 1) + ' / ' + files.length + ); + } + }, false); + return xhr; + }, + }); + } + }); +}); diff --git a/shared/media_archive/templates/imagekit/admin/selectable_thumbnail.html b/shared/media_archive/templates/imagekit/admin/selectable_thumbnail.html new file mode 100644 index 0000000..6d88740 --- /dev/null +++ b/shared/media_archive/templates/imagekit/admin/selectable_thumbnail.html @@ -0,0 +1,3 @@ +{% if thumbnail %} + +{% endif %} diff --git a/shared/media_archive/templates/media_archive/_figcaption.html b/shared/media_archive/templates/media_archive/_figcaption.html new file mode 100644 index 0000000..409598a --- /dev/null +++ b/shared/media_archive/templates/media_archive/_figcaption.html @@ -0,0 +1,25 @@ +{% load text_tags %} + + {% if object.caption %} +

+ {{ object.name|slimdown }} +

+ +
+ {{ object.caption|safe }} +
+ + {% if object.copyright %} + + {% endif %} + {% else %} + {{ object.name|slimdown }} + + {% if object.copyright %} + {{ object.copyright|slimdown }} + {% endif %} + {% endif %} diff --git a/shared/media_archive/templates/media_archive/admin/action_forms/add_to_category.html b/shared/media_archive/templates/media_archive/admin/action_forms/add_to_category.html new file mode 100644 index 0000000..8be643f --- /dev/null +++ b/shared/media_archive/templates/media_archive/admin/action_forms/add_to_category.html @@ -0,0 +1,27 @@ +{% extends "admin/change_form.html" %} + +{% load i18n %} + +{% block title %}{% trans "Add to working folder" %} {{ block.super }}{% endblock %} + +{% block content %} +
+

{% trans "Add files to working folder" %}

+ +
{% csrf_token %} +
+ {{ category_form.as_p }} + +

{% trans "Files to add:" %}

+ +
    + {{ mediafiles|unordered_list }} +
+ + + + +
+
+
+{% endblock %} diff --git a/shared/media_archive/templates/media_archive/admin/action_forms/change_is_public.html b/shared/media_archive/templates/media_archive/admin/action_forms/change_is_public.html new file mode 100644 index 0000000..f9a8fe1 --- /dev/null +++ b/shared/media_archive/templates/media_archive/admin/action_forms/change_is_public.html @@ -0,0 +1,35 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + + +{% block title %}{% trans "Set access mode for media files" %} {{ block.super }}{% endblock %} + + +{% block content %} +
+

{% trans "Set access mode for media files" %}

+ +

{% trans "Select access mode:" %}

+ +
+ {% csrf_token %} +
+ {{ action_form.as_p }} + + {% for obj in queryset %} + + {% endfor %} + +

{% trans "The following media files' access mode will be changed:" %}

+ +
    + {{ mediafiles|unordered_list }} +
+ + + + +
+
+
+{% endblock %} diff --git a/shared/media_archive/templates/media_archive/admin/select_filter.html b/shared/media_archive/templates/media_archive/admin/select_filter.html new file mode 100644 index 0000000..4d83296 --- /dev/null +++ b/shared/media_archive/templates/media_archive/admin/select_filter.html @@ -0,0 +1,21 @@ +{% load i18n %} + +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+
    +{% if choices|slice:"4:" %} +
  • + +
  • +{% else %} + {% for choice in choices %} + + {{ choice.display }} + {% endfor %} +{% endif %} +
diff --git a/shared/media_archive/templatetags/__init__.py b/shared/media_archive/templatetags/__init__.py new file mode 100644 index 0000000..e69de29