Browse Source

Initial import.

master 0.1
Erik Stein 6 years ago
commit
c9040ef4fc
  1. 10
      .gitignore
  2. 2
      AUTHORS
  3. 2
      CHANGES
  4. 28
      LICENSE
  5. 4
      MANIFEST.in
  6. 7
      README.md
  7. 2
      TODO
  8. 80
      setup.py
  9. 1
      shared/__init__.py
  10. 10
      shared/media_archive/__init__.py
  11. 237
      shared/media_archive/admin.py
  12. 98
      shared/media_archive/admin_actions.py
  13. 7
      shared/media_archive/apps.py
  14. 6
      shared/media_archive/conf.py
  15. 66
      shared/media_archive/forms.py
  16. 150
      shared/media_archive/migrations/0001_initial.py
  17. 0
      shared/media_archive/migrations/__init__.py
  18. 67
      shared/media_archive/mixins.py
  19. 330
      shared/media_archive/models.py
  20. 32
      shared/media_archive/static/media_archive/css/admin_file_drop.css
  21. 63
      shared/media_archive/static/media_archive/js/admin_file_drop.js
  22. 3
      shared/media_archive/templates/imagekit/admin/selectable_thumbnail.html
  23. 25
      shared/media_archive/templates/media_archive/_figcaption.html
  24. 27
      shared/media_archive/templates/media_archive/admin/action_forms/add_to_category.html
  25. 35
      shared/media_archive/templates/media_archive/admin/action_forms/change_is_public.html
  26. 21
      shared/media_archive/templates/media_archive/admin/select_filter.html
  27. 0
      shared/media_archive/templatetags/__init__.py

10
.gitignore vendored

@ -0,0 +1,10 @@
*.py?
*.sw?
*~
.coverage
.tox
/*.egg-info
build
dist
__pycache__
_version.py

2
AUTHORS

@ -0,0 +1,2 @@
Parts based on django-cabinet by Feinheit AG
Erik Stein <erik@classlibrary.net>

2
CHANGES

@ -0,0 +1,2 @@
0.1 2019-02-04
- Initial release.

28
LICENSE

@ -0,0 +1,28 @@
Copyright (c) 2017, Feinheit AG and individual contributors.
Copyright (c) 2018 Erik Stein <erik@classlibrary.net>
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.

4
MANIFEST.in

@ -0,0 +1,4 @@
include AUTHORS
include LICENSE
include README.md
recursive-include shared/media_archive/templates *

7
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.

2
TODO

@ -0,0 +1,2 @@
- Merge django-assetkit
- Generic extensibility for file metadata

80
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,
)

1
shared/__init__.py

@ -0,0 +1 @@
__import__('pkg_resources').declare_namespace(__name__)

10
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'

237
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(
"<small>{categories}</small><br>{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("<br>".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")

98
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")

7
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")

6
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)
)

66
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']

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

0
shared/media_archive/migrations/__init__.py

67
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})

330
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
)

32
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;
}

63
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 = $('<div class="progress">0 / ' + files.length + '</div>');
progress.appendTo(results);
for (var i=0; i<files.length; ++i) {
var d = new FormData();
d.append('csrfmiddlewaretoken', $('input[name=csrfmiddlewaretoken]').val());
d.append('file', files[i]);
$.ajax({
url: './upload/' + window.location.search,
type: 'POST',
data: d,
contentType: false,
processData: false,
success: function() {
progress.html('' + ++success + ' / ' + files.length);
if (success >= 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;
},
});
}
});
});

3
shared/media_archive/templates/imagekit/admin/selectable_thumbnail.html

@ -0,0 +1,3 @@
{% if thumbnail %}
<img src="{{ thumbnail.url }}">
{% endif %}

25
shared/media_archive/templates/media_archive/_figcaption.html

@ -0,0 +1,25 @@
{% load text_tags %}
{% if object.caption %}
<p class="name">
{{ object.name|slimdown }}
</p>
<div class="caption">
{{ object.caption|safe }}
</div>
{% if object.copyright %}
<p class="copyright">
{% if object.copyright %}
&copy; {{ object.copyright|slimdown }}
{% endif %}
</p>
{% endif %}
{% else %}
<span class="name">{{ object.name|slimdown }}</span>
{% if object.copyright %}
<span class="copyright">{{ object.copyright|slimdown }}</span>
{% endif %}
{% endif %}

27
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 %}
<div id="content-main">
<h1>{% trans "Add files to working folder" %}</h1>
<form action="" method="post">{% csrf_token %}
<div>
{{ category_form.as_p }}
<p>{% trans "Files to add:" %}</p>
<ul>
{{ mediafiles|unordered_list }}
</ul>
<input type="hidden" name="action" value="assign_category">
<input type="submit" name="apply" value="{% trans "Add" %}">
<input type="submit" name="cancel" value="{% trans "Cancel" %}">
</div>
</form>
</div>
{% endblock %}

35
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 %}
<div id="content-main">
<h1>{% trans "Set access mode for media files" %}</h1>
<p>{% trans "Select access mode:" %}</p>
<form action="" method="post">
{% csrf_token %}
<div>
{{ action_form.as_p }}
{% for obj in queryset %}
<input type="hidden" name="_selected_action" value="{{ obj.id }}">
{% endfor %}
<p>{% trans "The following media files' access mode will be changed:" %}</p>
<ul>
{{ mediafiles|unordered_list }}
</ul>
<input type="hidden" name="action" value="change_is_public_action">
<input type="submit" name="apply" value="{% trans "Change access mode" %}">
<input type="submit" name="cancel" value="{% trans "Cancel" %}">
</div>
</form>
</div>
{% endblock %}

21
shared/media_archive/templates/media_archive/admin/select_filter.html

@ -0,0 +1,21 @@
{% load i18n %}
<script type="text/javascript">var go_from_select = function(opt) { window.location = window.location.pathname + opt };</script>
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul class="admin-filter-{{ title|cut:' ' }}">
{% if choices|slice:"4:" %}
<li>
<select style="width: 95%;"
onchange="go_from_select(this.options[this.selectedIndex].value)">
{% for choice in choices %}
<option{% if choice.selected %} selected="selected"{% endif %}
value="{{ choice.query_string|iriencode }}">{{ choice.display }}</option>
{% endfor %}
</select>
</li>
{% else %}
{% for choice in choices %}
<li{% if choice.selected %} class="selected"{% endif %}>
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a></li>
{% endfor %}
{% endif %}
</ul>

0
shared/media_archive/templatetags/__init__.py

Loading…
Cancel
Save