This commit is contained in:
relikd
2024-06-27 21:13:47 +02:00
commit d44d74eb2c
72 changed files with 2444 additions and 0 deletions

View File

View File

@@ -0,0 +1,70 @@
from django import forms
from django.conf import settings
from django.contrib.admin.widgets import AdminFileWidget
from django.core.validators import FileExtensionValidator
from django.db import models
from django.forms import FileInput, widgets
from common.validators import MaxFilesizeValidator, readableToInt
MAX_UPLOAD_SIZE = '20 MB'
class AudioFileWidget(widgets.ClearableFileInput):
template_name = 'forms/audio-file.html'
class Media:
js = ['admin/file-upload-validator.js']
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['MEDIA_URL'] = settings.MEDIA_URL
return context
class AudioField(forms.FileField):
widget = AudioFileWidget
default_validators = [
FileExtensionValidator(['mp3']),
MaxFilesizeValidator(MAX_UPLOAD_SIZE)
]
def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)
if isinstance(widget, FileInput) and 'accept' not in widget.attrs:
attrs.setdefault('accept', 'audio/mpeg') # audio/*
if isinstance(widget, AudioFileWidget):
attrs.update({
'data-upload-limit': readableToInt(MAX_UPLOAD_SIZE),
'data-upload-limit-str': MAX_UPLOAD_SIZE,
'onchange': 'validate_upload_limit(this)',
})
return attrs
class AudioFileField(models.FileField):
__del_file_on_save = False
def formfield(self, **kwargs):
if kwargs['widget'] is AdminFileWidget:
# Override admin widget. Defined by AudioField anyway
del kwargs['widget']
return super().formfield(**{'form_class': AudioField, **kwargs})
def save_form_data(self, instance, data):
if data is False:
self.__del_file_on_save = True
super().save_form_data(instance, data)
def pre_save(self, model_instance, add):
if self.__del_file_on_save:
self.__del_file_on_save = False
self.deletePreviousFile(model_instance)
return super().pre_save(model_instance, add)
def deletePreviousFile(self, instance: models.Model):
if not instance.pk:
return
prev = instance.__class__.objects.get(pk=instance.pk)
fileField = getattr(prev, self.attname)
fileField.delete(save=False)

View File

@@ -0,0 +1,67 @@
from django import forms
from django.conf import settings
from django.contrib.admin.widgets import AdminFileWidget
from django.core.validators import FileExtensionValidator
from django.db import models
from django.forms import FileInput, widgets
from common.validators import MaxFilesizeValidator, readableToInt
MAX_UPLOAD_SIZE = '312 KB'
class ImageFileWidget(widgets.ClearableFileInput):
template_name = 'forms/img-with-preview.html'
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['MEDIA_URL'] = settings.MEDIA_URL
return context
class ImgField(forms.FileField):
widget = ImageFileWidget
default_validators = [
FileExtensionValidator(['jpg', 'jpeg', 'png']),
MaxFilesizeValidator(MAX_UPLOAD_SIZE),
]
def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)
if isinstance(widget, FileInput) and 'accept' not in widget.attrs:
attrs.setdefault('accept', 'image/png,image/jpeg') # image/*
if isinstance(widget, ImageFileWidget):
attrs.update({
'data-upload-limit': readableToInt(MAX_UPLOAD_SIZE),
'data-upload-limit-str': MAX_UPLOAD_SIZE,
'onchange': 'validate_upload_limit(this)',
})
return attrs
class FileWithImagePreview(models.FileField): # use ImageField to omit Pillow
__del_image_on_save = False
def formfield(self, **kwargs):
if kwargs['widget'] is AdminFileWidget:
# Override admin widget. Defined by ImgField anyway
del kwargs['widget']
return super().formfield(**{'form_class': ImgField, **kwargs})
def save_form_data(self, instance, data):
if data is False:
self.__del_image_on_save = True
super().save_form_data(instance, data)
def pre_save(self, model_instance, add):
if self.__del_image_on_save:
self.__del_image_on_save = False
self.deletePreviousImage(model_instance)
return super().pre_save(model_instance, add)
def deletePreviousImage(self, instance: models.Model):
if not instance.pk:
return
prev = instance.__class__.objects.get(pk=instance.pk)
imgField = getattr(prev, self.attname)
imgField.delete(save=False)

Binary file not shown.

View File

@@ -0,0 +1,8 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
msgid "File size too large (max. %(limit_value)s)."
msgstr "Datei zu groß (max. %(limit_value)s)."

View File

@@ -0,0 +1,30 @@
TINYMCE_DEFAULT_CONFIG = {
"width": "100%",
# see https://www.tiny.cloud/docs/tinymce/latest/available-menu-items/
"menubar": "edit view insert format table help", # file tools
"plugins": "advlist autolink lists link image charmap anchor "
"searchreplace visualblocks code fullscreen insertdatetime media table "
"help", # paste print preview wordcount
# see https://www.tiny.cloud/docs/tinymce/latest/available-toolbar-buttons/
"toolbar": "undo redo | blocks | bold italic removeformat | "
"bullist numlist outdent indent",
# forecolor backcolor | alignleft aligncenter alignright alignjustify
"promotion": False, # hide upgrade button
"language": "de_DE",
# "custom_undo_redo_levels": 10,
"images_file_types": 'jpeg,jpg,png,gif',
"images_upload_handler": "tinymce_image_upload_handler",
"images_reuse_filename": True, # prevent image edits from reuploading imgs
"convert_urls": False,
}
TINYMCE_EXTRA_MEDIA = {
'css': {'all': []},
'js': ['admin/tinymce-upload.js'],
}

View File

@@ -0,0 +1,6 @@
function validate_upload_limit(sender) {
if (sender.files[0].size > sender.dataset.uploadLimit) {
sender.value = '';
alert(`Datei zu groß (max. ${sender.dataset.uploadLimitStr})`);
}
}

View File

@@ -0,0 +1,55 @@
function tinymce_image_upload_handler(blobInfo, progress) {
return new Promise((resolve, reject) => {
const match = self.location.pathname.match('/app/place/([0-9]*)/');
if (!match) {
return reject('Cannot match place id from URL.');
}
// FIXME: this will still upload the image as base64 string
// if (blobInfo.blob().size > 1_000_000) { // >1MB
// return reject('Image too large. Max file size: 1 MB');
// }
const placeId = match[1];
let xhr, formData;
// token = Cookies.get("csrftoken");
token = document.cookie.match('csrftoken=([^;]*)')[1];
xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', '/tinymce/upload/' + placeId + '/');
xhr.setRequestHeader('X-CSRFToken', token);
if (progress) {
xhr.upload.onprogress = function (e) {
progress(e.loaded / e.total * 100);
};
}
xhr.onload = function () {
let json;
if (xhr.status === 403) {
return reject('HTTP Error: ' + xhr.status, { remove: true });
}
if (xhr.status < 200 || xhr.status >= 300) {
return reject('HTTP Error: ' + xhr.status);
}
json = JSON.parse(xhr.responseText);
if (!json || typeof json.location != 'string') {
return reject('Invalid JSON: ' + xhr.responseText);
}
return resolve(json.location);
};
xhr.onerror = function () {
return reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(formData);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

View File

@@ -0,0 +1,17 @@
<div style="width: 100%">
{% if widget.is_initial %}
<audio controls preload="none" style="width: 100%">
<source src="{{MEDIA_URL}}{{widget.value}}" type="audio/mpeg">
Browser does not support Audio
</audio>
{% if not widget.required %}
<span class="clearable-file-input">
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
</span>
{% endif %}
<br>
<span>{{ widget.input_text }}:</span>
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</div>

View File

@@ -0,0 +1,14 @@
<div>
{% if widget.is_initial %}
<img src="{{MEDIA_URL}}{{widget.value}}" style="max-width: 150px; max-height: 150px; margin: 8px;" />
{% if not widget.required %}
<span class="clearable-file-input">
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
</span>
{% endif %}
<br>
<span>{{ widget.input_text }}:</span>
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</div>

36
backend/common/urls.py Normal file
View File

@@ -0,0 +1,36 @@
from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.urls import include, path
from pathlib import Path
from app.models.place import Place
def tinymce_upload(request: HttpRequest, placeId: int):
if request.method != 'POST':
return JsonResponse({'error': 'unsupported method type'})
try:
Place.objects.get(pk=placeId)
except Place.DoesNotExist:
return JsonResponse({'error': 'place does not exist'})
file = request.FILES.get('file')
if not file:
return JsonResponse({'error': 'could not read file'})
save_dir = Path(settings.MEDIA_ROOT) / str(placeId)
if not save_dir.exists():
save_dir.mkdir()
with open(save_dir / file.name, 'wb') as fp:
fp.write(file.read())
fname = f'{placeId}/{file.name}'
return JsonResponse({'location': settings.MEDIA_URL + fname})
urlpatterns = [
path('tinymce/', include('tinymce.urls')),
path('tinymce/upload/<int:placeId>/', tinymce_upload),
]

View File

@@ -0,0 +1,23 @@
from django.core.validators import BaseValidator
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
from django.core.files.uploadedfile import TemporaryUploadedFile
UNITS = {'k': 1000, 'm': 1000_000, 'g': 1000_000_000}
def readableToInt(limit: str) -> int:
x = limit.lower().rstrip(' ib') # KiB & KB -> k
multiply = UNITS.get(x[-1], 1)
value = float(x.rstrip(' _kmg').replace(',', '.'))
return int(value * multiply)
@deconstructible
class MaxFilesizeValidator(BaseValidator):
message = _('File size too large (max. %(limit_value)s).')
code = 'max_filesize'
def compare(self, a: TemporaryUploadedFile, limit: str):
return a.size > readableToInt(limit)