From e242e4bd2fde4ee57434087fcaa85f5bcdcb53ff Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 10:20:43 +0200 Subject: [PATCH 1/8] implemented admin preview, helper methods and download view --- .gitignore | 5 +- docs/source/installation.rst | 8 --- dynamic_file/admin.py | 16 ++++- dynamic_file/config.py | 3 - dynamic_file/migrations/0001_initial.py | 6 +- .../0003_alter_dynamicfile_uploaded_by.py | 27 +++++++++ dynamic_file/models/base.py | 58 +++++++++++++++++-- dynamic_file/models/file.py | 12 +--- dynamic_file/serializers/__init__.py | 1 - dynamic_file/serializers/dynamic_file.py | 0 dynamic_file/storage.py | 2 +- dynamic_file/views/serve_file.py | 16 +++-- test_app/admin.py | 20 +++++++ test_app/urls.py | 4 ++ test_settings.py | 5 +- tests/test_model_file.py | 6 -- 16 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py delete mode 100644 dynamic_file/serializers/dynamic_file.py create mode 100644 test_app/admin.py diff --git a/.gitignore b/.gitignore index 3a68c4f..ed16619 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,6 @@ dmypy.json # user configured: _build/ test_files - -cov.xml +static/ + +cov.xml diff --git a/docs/source/installation.rst b/docs/source/installation.rst index d9ab966..e7f02fa 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -54,14 +54,6 @@ DYNAMIC_FILE_UPLOADED_BY_MODEL This setting defines the foreign key that determines which entity has uploaded the file. It defaults to the user model. -DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME -**************************************************** -.. code-block:: python3 - - DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME = 'uploaded_files' - -Defines the related name for all uploaded files that is added to the model specified by ``DYNAMIC_FILE_UPLOADED_BY_MODEL``. - DYNAMIC_FILE_UPLOADED_BY_MODEL_MIGRATION_DEPENDENCY **************************************************** .. code-block:: python3 diff --git a/dynamic_file/admin.py b/dynamic_file/admin.py index 8f01233..63e026e 100644 --- a/dynamic_file/admin.py +++ b/dynamic_file/admin.py @@ -1,8 +1,22 @@ from django.contrib import admin from .models import DynamicFile +from django.utils.safestring import mark_safe + + +def preview(dynamic_file): + mimetype = dynamic_file.mimetype + if mimetype and 'image' in mimetype: + src = dynamic_file.to_base64_src() + return mark_safe(f'') + else: + return 'No preview available' @admin.register(DynamicFile) -class CourseGroupAdmin(admin.ModelAdmin): +class DynamicFileAdmin(admin.ModelAdmin): list_display = ['id', 'name'] + readonly_fields = ['preview'] + + def preview(self, instance): + return preview(instance) diff --git a/dynamic_file/config.py b/dynamic_file/config.py index d2aa9f8..b5b5ca9 100644 --- a/dynamic_file/config.py +++ b/dynamic_file/config.py @@ -8,6 +8,3 @@ if not hasattr(settings, 'DYNAMIC_FILE_UPLOADED_BY_MIGRATION_DEPENDENCY'): setattr(settings, 'DYNAMIC_FILE_UPLOADED_BY_MIGRATION_DEPENDENCY', '__first__') - -if not hasattr(settings, 'DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME'): - setattr(settings, 'DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME', 'uploaded_files') diff --git a/dynamic_file/migrations/0001_initial.py b/dynamic_file/migrations/0001_initial.py index 6d671ef..885ab60 100644 --- a/dynamic_file/migrations/0001_initial.py +++ b/dynamic_file/migrations/0001_initial.py @@ -10,7 +10,7 @@ # Heavily inspired by https://github.com/dj-stripe/dj-stripe/blob/master/djstripe/migrations/0001_initial.py DYNAMIC_FILE_UPLOADED_BY_MODEL = settings.DYNAMIC_FILE_UPLOADED_BY_MODEL -DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME = settings.DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME +DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME = '+' DYNAMIC_FILE_UPLOADED_BY_MODEL_MIGRATION_DEPENDENCY = settings.DYNAMIC_FILE_UPLOADED_BY_MIGRATION_DEPENDENCY @@ -27,6 +27,7 @@ DYNAMIC_FILE_UPLOADED_BY_MODEL, ) + class Migration(migrations.Migration): initial = True @@ -42,7 +43,8 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('description', models.TextField(blank=True, help_text='A description for this file')), ('file', models.FileField(help_text='The uploaded file', upload_to='')), - ('uploaded_by', models.ForeignKey(help_text='The owner/uploader of this file', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name=DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME, to=DYNAMIC_FILE_UPLOADED_BY_MODEL)), + ('uploaded_by', models.ForeignKey(help_text='The owner/uploader of this file', null=True, + on_delete=django.db.models.deletion.SET_NULL, related_name=DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME, to=DYNAMIC_FILE_UPLOADED_BY_MODEL)), ], options={ 'abstract': False, diff --git a/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py new file mode 100644 index 0000000..0dd5c98 --- /dev/null +++ b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.3 on 2023-07-07 07:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('test_app', '0003_testmodelonetoone'), + ('dynamic_file', '0002_alter_dynamicfile_file'), + ] + + operations = [ + migrations.AlterField( + model_name='dynamicfile', + name='uploaded_by', + field=models.ForeignKey(blank=True, help_text='The owner/uploader of this file', null=True, + on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='test_app.testmodel'), + ), + migrations.AddField( + model_name='dynamicfile', + name='display_name', + field=models.CharField(blank=True, default='', + help_text='An optional displayable name for this file.', max_length=128), + ), + ] diff --git a/dynamic_file/models/base.py b/dynamic_file/models/base.py index d59feb8..7b4ecf5 100644 --- a/dynamic_file/models/base.py +++ b/dynamic_file/models/base.py @@ -1,12 +1,24 @@ from django.db import models from django.utils.translation import gettext as _ from django.conf import settings +import mimetypes + +import base64 class DynamicFileBase(models.Model): ''' - Base model for handling dynamic files. Should not be used on it's own by - consuming applications. + Base model for handling dynamic files. Abstract, cannot be used directly. + ''' + + display_name = models.CharField( + blank=True, + default='', + max_length=128, + help_text=_('An optional displayable name for this file.') + ) + ''' + A concise and optional description of the uploaded file. ''' description = models.TextField( @@ -21,17 +33,53 @@ class DynamicFileBase(models.Model): settings.DYNAMIC_FILE_UPLOADED_BY_MODEL, on_delete=models.SET_NULL, null=True, - related_name=settings.DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME, + blank=True, + related_name='+', help_text=_('The owner/uploader of this file') ) ''' Specifies the uploader of this file. This foreign key defaults to AUTH_USER_MODEL but can be customized by changing DYNAMIC_FILE_UPLOADED_BY_MODEL. - The reverse accessor defaults to 'uploaded_files' and can be customized by changing - DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME. + The reverse accessor is unused. NOTE: Changing those settings is only supported _before_ running the first migration ''' + @property + def name(self): + return self.file.name + + @property + def read(self): + return self.file.read + + @property + def mimetype(self): + return mimetypes.MimeTypes().guess_type(self.name)[0] + + def to_base64(self): + return base64.b64encode(self.read()) + + def to_base64_utf8(self): + return self.to_base64().decode('utf-8') + + def to_base64_src(self): + src = self.to_base64_utf8() + return f'data:;base64,{src}' + + def __str__(self): + ''' + String representation of this model. + If a display name is set, this will be used as representation. + If no display name is set, the filename is used. + If no filename is set, the pk and a `_nofile` suffix is used. + ''' + if len(self.display_name) > 0: + return self.display_name + elif len(self.name) > 0: + return self.name + else: + return f'{self.pk}_nofile' + class Meta: abstract = True diff --git a/dynamic_file/models/file.py b/dynamic_file/models/file.py index 4eba3a0..5078efd 100644 --- a/dynamic_file/models/file.py +++ b/dynamic_file/models/file.py @@ -3,6 +3,7 @@ from dynamic_file.models.base import DynamicFileBase from dynamic_file.storage import DynamicFileSystemStorage + fs = DynamicFileSystemStorage() @@ -14,14 +15,3 @@ class DynamicFile(DynamicFileBase): ''' The concrete file for this model. ''' - - @property - def name(self): - return self.file.name - - def __str__(self): - ''' - String representation of this model. Defaults to the name of the file. - If no file has been set, returnes the pk with a fixed suffix. - ''' - return self.file.name if self.file.name else f'{self.pk}_nofile' diff --git a/dynamic_file/serializers/__init__.py b/dynamic_file/serializers/__init__.py index 878507c..2d380f9 100644 --- a/dynamic_file/serializers/__init__.py +++ b/dynamic_file/serializers/__init__.py @@ -1,2 +1 @@ from .fields import * # noqa: F401,F403 -from .dynamic_file import * # noqa: F401,F403 diff --git a/dynamic_file/serializers/dynamic_file.py b/dynamic_file/serializers/dynamic_file.py deleted file mode 100644 index e69de29..0000000 diff --git a/dynamic_file/storage.py b/dynamic_file/storage.py index e57ce25..363b98d 100644 --- a/dynamic_file/storage.py +++ b/dynamic_file/storage.py @@ -4,4 +4,4 @@ class DynamicFileSystemStorage(FileSystemStorage): def __init__(self, location=settings.DYNAMIC_FILE_STORAGE_LOCATION): - super().__init__(location=location, base_url='') + super().__init__(location=location, base_url='/admin/download/') diff --git a/dynamic_file/views/serve_file.py b/dynamic_file/views/serve_file.py index a016a85..d3138ef 100644 --- a/dynamic_file/views/serve_file.py +++ b/dynamic_file/views/serve_file.py @@ -8,6 +8,8 @@ from dynamic_file.models import DynamicFile from rest_framework.settings import api_settings +from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAdminUser import mimetypes import os @@ -32,7 +34,9 @@ def get_parent(self): def get_object(self): filter_kwargs = {self.lookup_field: self.kwargs[self.lookup_url_kwarg]} - return self._Model.objects.filter(**filter_kwargs).first() + instance = self._Model.objects.filter(**filter_kwargs).first() + self.check_object_permissions(self.request, instance) + return instance class ServeDynamicFile(_DynamicContentMixin, APIView): @@ -42,13 +46,9 @@ def get(self, request, *args, **kwargs): if instance: filename = instance.file.name - # fallback would go here. - content_type, encoding = mimetypes.guess_type(filename) path = os.path.join(settings.DYNAMIC_FILE_STORAGE_LOCATION, filename) - # TODO check if exists and add fallback - if settings.DEBUG: fp = open(path, 'rb') response = FileResponse(fp) @@ -63,3 +63,9 @@ def get(self, request, *args, **kwargs): response['X-Accel-Redirect'] = location return response + + +class ServeDynamicFileAdmin(ServeDynamicFile): + permission_classes = [IsAuthenticated, IsAdminUser] + lookup_field = 'file' + lookup_url_kwarg = 'name' diff --git a/test_app/admin.py b/test_app/admin.py new file mode 100644 index 0000000..a6f8b4b --- /dev/null +++ b/test_app/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from test_app.models import TestModelOneToOne +from test_app.models import TestModel + +from dynamic_file.admin import preview as image_preview + + +@admin.register(TestModel) +class TestModelAdmin(admin.ModelAdmin): + pass + + +@admin.register(TestModelOneToOne) +class TestModelOneToOneAdmin(admin.ModelAdmin): + fields = ['file', 'preview'] + readonly_fields = ['preview'] + + def preview(self, instance): + return image_preview(instance.file) diff --git a/test_app/urls.py b/test_app/urls.py index 414e78b..5a168f3 100644 --- a/test_app/urls.py +++ b/test_app/urls.py @@ -1,6 +1,8 @@ from django.urls import path +from django.contrib import admin from dynamic_file.views import ServeDynamicFile +from dynamic_file.views import ServeDynamicFileAdmin from test_app.views import ServeTestFile @@ -9,4 +11,6 @@ path('serve/', ServeDynamicFile.as_view(), name='serve_default'), path('customServe/', ServeTestFile.as_view(), name='serve_custom'), + path("admin/download/", ServeDynamicFileAdmin.as_view()), + path("admin/", admin.site.urls), ] diff --git a/test_settings.py b/test_settings.py index dc54c2b..3a2024c 100644 --- a/test_settings.py +++ b/test_settings.py @@ -13,7 +13,7 @@ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = Path(__file__).resolve().parent # Quick-start development settings - unsuitable for production @@ -37,6 +37,7 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', 'test_app', 'dynamic_file', @@ -117,6 +118,7 @@ # https://docs.djangoproject.com/en/4.1/howto/static-files/ STATIC_URL = 'static/' +STATIC_ROOT = 'static' # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field @@ -127,5 +129,4 @@ # dynamic_file settings: DYNAMIC_FILE_STORAGE_LOCATION = 'test_files' DYNAMIC_FILE_UPLOADED_BY_MODEL = 'test_app.TestModel' -DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME = 'files_uploaded' DEFAULT_FILE_STORAGE = 'dynamic_file.storage.DynamicFileSystemStorage' diff --git a/tests/test_model_file.py b/tests/test_model_file.py index 9647612..38d0286 100644 --- a/tests/test_model_file.py +++ b/tests/test_model_file.py @@ -18,12 +18,6 @@ def test_create_default(self): assert instance.uploaded_by is None instance.file.name == '' - def test_reverse_accessor(self): - uploader = UploadedByModel.objects.create() - instance = DynamicFile.objects.create(uploaded_by=uploader) - - assert instance in getattr(uploader, settings.DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME).all() - def test_description(self): desc = 'A sample description for this file' instance = DynamicFile.objects.create(description=desc) From f39c7b3787f09f146dc79ad5f1dbd526a3854b8f Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 10:55:10 +0200 Subject: [PATCH 2/8] testcases --- dynamic_file/models/base.py | 4 ++- dynamic_file/views/serve_file.py | 14 +++-------- tests/helpers.py | 2 +- tests/test_admin.py | 22 +++++++++++++++++ tests/test_model_file.py | 42 +++++++++++++++++++++++++++++++- tests/test_view_serve.py | 8 ++++++ 6 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 tests/test_admin.py diff --git a/dynamic_file/models/base.py b/dynamic_file/models/base.py index 7b4ecf5..4671f54 100644 --- a/dynamic_file/models/base.py +++ b/dynamic_file/models/base.py @@ -47,7 +47,9 @@ class DynamicFileBase(models.Model): @property def name(self): - return self.file.name + if self.file: + return self.file.name + return '' @property def read(self): diff --git a/dynamic_file/views/serve_file.py b/dynamic_file/views/serve_file.py index d3138ef..b303ef1 100644 --- a/dynamic_file/views/serve_file.py +++ b/dynamic_file/views/serve_file.py @@ -2,8 +2,8 @@ from django.http.response import FileResponse from django.http.response import HttpResponse -# from rest_framework.generics import RetrieveUpdateDestroyAPIView from rest_framework.views import APIView +from rest_framework.exceptions import NotFound from dynamic_file.models import DynamicFile @@ -22,16 +22,6 @@ class _DynamicContentMixin(): lookup_field = 'pk' lookup_url_kwarg = 'pk' - def get_file_name(self, file, _): - _, ext = os.path.splitext(file.name) - if not ext: - return file.name + mimetypes.guess_extension(file.content_type) - else: # pragma: no cover - return file.name - - def get_parent(self): - return None - def get_object(self): filter_kwargs = {self.lookup_field: self.kwargs[self.lookup_url_kwarg]} instance = self._Model.objects.filter(**filter_kwargs).first() @@ -45,6 +35,8 @@ def get(self, request, *args, **kwargs): filename = None if instance: filename = instance.file.name + else: + raise NotFound() content_type, encoding = mimetypes.guess_type(filename) path = os.path.join(settings.DYNAMIC_FILE_STORAGE_LOCATION, filename) diff --git a/tests/helpers.py b/tests/helpers.py index 1c23e91..051383b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -8,4 +8,4 @@ def create_dummy_gif(name='test.gif'): - return SimpleUploadedFile('test.gif', SMALL_GIF) + return SimpleUploadedFile(name, SMALL_GIF) diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..3fac1a5 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,22 @@ + +from django.test import TestCase + +from dynamic_file.models import DynamicFile +from dynamic_file.admin import preview +import helpers + + +class AdminTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.instance_1 = DynamicFile.objects.create(file=helpers.create_dummy_gif()) + cls.instance_2 = DynamicFile.objects.create(file=helpers.create_dummy_gif('not_a_gif.exe')) + + def test_preview(self): + response = preview(self.instance_1) + assert ' Date: Fri, 7 Jul 2023 12:08:43 +0200 Subject: [PATCH 3/8] add documentation --- docs/source/api.rst | 15 ++++++++++++ docs/source/changelog.rst | 32 +++++++++++++++++++++++++ docs/source/conf.py | 2 +- docs/source/index.rst | 5 +++- docs/source/usage.rst | 47 ++++++++++++++++++++++++++++++++++++- dynamic_file/models/base.py | 23 ++++++++++++++++-- 6 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 docs/source/api.rst create mode 100644 docs/source/changelog.rst diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..ee7a56c --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,15 @@ +.. _api: + +###################### +API +###################### + +.. currentmodule:: dynamic_file.models + +================ +DynamicFile +================ +.. autoclass:: DynamicFile + :members: + :inherited-members: Model + :noindex: diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..39bee41 --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,32 @@ +.. _changelog: + +###################### +Changelog +###################### +This document provides an overview about breaking changes and new features. + + +*************************************************** +0.4.0 +*************************************************** + +Changes +**************************************************** +The setting ``DYNAMIC_FILE_UPLOADED_BY_RELATED_NAME`` has been removed. +If you want to know the uploaded files from your ``uploader entity``, use the following: + +.. code-block:: python3 + + DynamicFile.objects.filter(uploaded_by=your_entity_id) + +Features +**************************************************** + +* If the file is an image, a preview will be shown in the admin (relies on ``mimetypes``) +* Added a field ``display_name`` to provide a human-readable name for dynamic files +* Added some ``base64`` utilities for commonly used tasks + +*************************************************** +0.3.0 +*************************************************** +First useable version diff --git a/docs/source/conf.py b/docs/source/conf.py index e76c285..1897f53 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,7 +13,7 @@ project = 'django-dynamic-file' -copyright = '2022, Philipp Hafner' +copyright = '2023, Philipp Hafner' author = 'Philipp Hafner' release = '0.0' diff --git a/docs/source/index.rst b/docs/source/index.rst index 58275fe..0332789 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,12 +13,15 @@ This library aims at providing an easy interface for handling files in conjuncti methods for applying generic permissions to serving, updating and deleting files. This is done by wrapping `FileField` in their own model. - The following documents provide a guide on how to install, configure and use this library. + .. toctree:: :maxdepth: 1 installation configuration usage + changelog + api + diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 1fc2e67..704695d 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -146,4 +146,49 @@ There is a provided default view which handles file serving via a passed ``pk``, ] -Now, this view name (``serve_default``) can be used in serializers, as described above. \ No newline at end of file +Now, this view name (``serve_default``) can be used in serializers, as described above. + + +*************************************************** +Admin integration +*************************************************** +``DynamicFile`` has a pretty basic but useful admin integration. +It supports a preview rendering in case the file is an image. +In case it's not an image, no preview will be shown. + + +.. code-block:: python3 + + from .models import Company + from django.contrib import admin + + from dynamic_file.admin import preview as image_preview + + @admin.register(Company) + class CompanyAdmin(admin.ModelAdmin): + fields = ['image', 'preview'] + readonly_fields = ['preview'] + + def preview(self, instance): + return image_preview(instance.image) + + +Files can also be downloaded from within the admin with a provided view. +Simply add the following to your ``urls.py`` and make sure that you place it +**before** the actual import of the admin pages: + +.. code-block:: python3 + + from dynamic_file.views import ServeDynamicFileAdmin + + urlpatterns = [ + + #... your other includes + + path('admin/download/', ServeDynamicFileAdmin.as_view()), + path('admin/', admin.site.urls), + ] + + + +This enables downloadable files for every user that has access to the admin page. diff --git a/dynamic_file/models/base.py b/dynamic_file/models/base.py index 4671f54..44405f3 100644 --- a/dynamic_file/models/base.py +++ b/dynamic_file/models/base.py @@ -38,8 +38,8 @@ class DynamicFileBase(models.Model): help_text=_('The owner/uploader of this file') ) ''' - Specifies the uploader of this file. This foreign key defaults to AUTH_USER_MODEL - but can be customized by changing DYNAMIC_FILE_UPLOADED_BY_MODEL. + Specifies the uploader of this file. This foreign key defaults to ``AUTH_USER_MODEL`` + but can be customized by changing ``DYNAMIC_FILE_UPLOADED_BY_MODEL``. The reverse accessor is unused. NOTE: Changing those settings is only supported _before_ running the first migration @@ -47,25 +47,44 @@ class DynamicFileBase(models.Model): @property def name(self): + ''' + Returns the filename of this instance. If ``file`` is ``None``, an empty string is returned. + ''' if self.file: return self.file.name return '' @property def read(self): + ''' + Wrapper for the read method of the ``FileField`` instance + ''' return self.file.read @property def mimetype(self): + ''' + Guesses the ``mimetype`` from the file extension. Returns null if unknown + ''' return mimetypes.MimeTypes().guess_type(self.name)[0] def to_base64(self): + ''' + Converts the file to base64. Undefined if the file is ``None`` + ''' return base64.b64encode(self.read()) def to_base64_utf8(self): + ''' + Converts the file to base64 and returns it in ``utf-8`` encoding. + ''' return self.to_base64().decode('utf-8') def to_base64_src(self): + ''' + Converts the file to base64 and returns it in ``utf-8`` encoding. In addition, + the content is wrapped to be used within html ``src=`` attributes + ''' src = self.to_base64_utf8() return f'data:;base64,{src}' From 61aade2fccaf5b3ae76b92f44d9ca1724cd98a80 Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 12:09:27 +0200 Subject: [PATCH 4/8] added translation string for admin --- dynamic_file/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dynamic_file/admin.py b/dynamic_file/admin.py index 63e026e..5651dfe 100644 --- a/dynamic_file/admin.py +++ b/dynamic_file/admin.py @@ -2,6 +2,7 @@ from .models import DynamicFile from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ def preview(dynamic_file): @@ -10,7 +11,7 @@ def preview(dynamic_file): src = dynamic_file.to_base64_src() return mark_safe(f'') else: - return 'No preview available' + return _('No preview available') @admin.register(DynamicFile) From a39de8782218eb347f5ac121883095f3f34904a0 Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 12:13:07 +0200 Subject: [PATCH 5/8] fixed version info --- docs/source/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 39bee41..6dbaee6 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -7,7 +7,7 @@ This document provides an overview about breaking changes and new features. *************************************************** -0.4.0 +0.5.0 *************************************************** Changes @@ -27,6 +27,6 @@ Features * Added some ``base64`` utilities for commonly used tasks *************************************************** -0.3.0 +0.4.0 *************************************************** First useable version From 081fc3f2b133a0f1467897d0d889ce4367c61dd5 Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 12:13:20 +0200 Subject: [PATCH 6/8] bump version 0.4.0 -> 0.5.0 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8fd837c..948e4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "django-dynamic-file" -version = "0.4.0" +version = "0.5.0" description = "A flexible approach to handling and serving files with django" readme = "README.rst" authors = [{ name = "Philipp Hafner", email = "philipp@hafner.xyz" }] @@ -35,7 +35,7 @@ package-dir = {"dynamic_file" = "dynamic_file"} [tool.bumpver] version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" -current_version = "0.4.0" +current_version = "0.5.0" commit_message = "bump version {old_version} -> {new_version}" commit = true tag = false From 27e237367e6d7c44b2edf6e23df5fd991d97849e Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 12:23:22 +0200 Subject: [PATCH 7/8] migration fix --- dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py index 0dd5c98..02cc7ac 100644 --- a/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py +++ b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): dependencies = [ - ('test_app', '0003_testmodelonetoone'), ('dynamic_file', '0002_alter_dynamicfile_file'), ] From 69939cefe8980a8a5f8d90be91886d86e46d5d6d Mon Sep 17 00:00:00 2001 From: Philipp Hafner Date: Fri, 7 Jul 2023 12:55:40 +0200 Subject: [PATCH 8/8] migration fix and hardening --- dynamic_file/admin.py | 15 ++++++----- .../0003_alter_dynamicfile_uploaded_by.py | 6 ++++- dynamic_file/views/serve_file.py | 27 +++++++++++-------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/dynamic_file/admin.py b/dynamic_file/admin.py index 5651dfe..1a321d3 100644 --- a/dynamic_file/admin.py +++ b/dynamic_file/admin.py @@ -6,12 +6,15 @@ def preview(dynamic_file): - mimetype = dynamic_file.mimetype - if mimetype and 'image' in mimetype: - src = dynamic_file.to_base64_src() - return mark_safe(f'') - else: - return _('No preview available') + try: + mimetype = dynamic_file.mimetype + if mimetype and 'image' in mimetype: + src = dynamic_file.to_base64_src() + return mark_safe(f'') + else: + return _('No preview available') + except Exception as e: + return _(f'No preview available: {str(e)}') @admin.register(DynamicFile) diff --git a/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py index 02cc7ac..952d664 100644 --- a/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py +++ b/dynamic_file/migrations/0003_alter_dynamicfile_uploaded_by.py @@ -4,6 +4,10 @@ import django.db.models.deletion +from django.conf import settings +DYNAMIC_FILE_UPLOADED_BY_MODEL = settings.DYNAMIC_FILE_UPLOADED_BY_MODEL + + class Migration(migrations.Migration): dependencies = [ @@ -15,7 +19,7 @@ class Migration(migrations.Migration): model_name='dynamicfile', name='uploaded_by', field=models.ForeignKey(blank=True, help_text='The owner/uploader of this file', null=True, - on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='test_app.testmodel'), + on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=DYNAMIC_FILE_UPLOADED_BY_MODEL), ), migrations.AddField( model_name='dynamicfile', diff --git a/dynamic_file/views/serve_file.py b/dynamic_file/views/serve_file.py index b303ef1..26ad18c 100644 --- a/dynamic_file/views/serve_file.py +++ b/dynamic_file/views/serve_file.py @@ -3,6 +3,7 @@ from django.http.response import HttpResponse from rest_framework.views import APIView +from rest_framework import status from rest_framework.exceptions import NotFound from dynamic_file.models import DynamicFile @@ -41,20 +42,24 @@ def get(self, request, *args, **kwargs): content_type, encoding = mimetypes.guess_type(filename) path = os.path.join(settings.DYNAMIC_FILE_STORAGE_LOCATION, filename) - if settings.DEBUG: - fp = open(path, 'rb') - response = FileResponse(fp) - response.filename = filename + try: + if settings.DEBUG: + fp = open(path, 'rb') + response = FileResponse(fp) + response.filename = filename - else: # for now, nginx will suffice - response = HttpResponse(content_type=content_type) - response['Content-Disposition'] = 'inline; filename={0}'.format(filename) - response['Content-Encoding'] = encoding + else: # for now, nginx will suffice + response = HttpResponse(content_type=content_type) + response['Content-Disposition'] = 'inline; filename={0}'.format(filename) + response['Content-Encoding'] = encoding - location = os.path.normpath(path) - response['X-Accel-Redirect'] = location + location = os.path.normpath(path) + response['X-Accel-Redirect'] = location - return response + return response + except Exception as e: + print(e) + return HttpResponse('File not found', status=status.HTTP_404_NOT_FOUND) class ServeDynamicFileAdmin(ServeDynamicFile):