Thumbnails for images and videos #87
@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
|
|||||||
import factory
|
import factory
|
||||||
import factory.fuzzy
|
import factory.fuzzy
|
||||||
|
|
||||||
from extensions.models import Extension, Version, Tag
|
from extensions.models import Extension, Version, Tag, Preview
|
||||||
from ratings.models import Rating
|
from ratings.models import Rating
|
||||||
|
|
||||||
fake_markdown = Faker()
|
fake_markdown = Faker()
|
||||||
@ -35,7 +35,7 @@ class ExtensionFactory(DjangoModelFactory):
|
|||||||
|
|
||||||
if extracted:
|
if extracted:
|
||||||
for _ in extracted:
|
for _ in extracted:
|
||||||
_.extension_preview.create(caption='Media Caption', extension=self)
|
Preview.objects.create(file=_, caption='Media Caption', extension=self)
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def process_extension_id(self, created, extracted, **kwargs):
|
def process_extension_id(self, created, extracted, **kwargs):
|
||||||
|
@ -66,24 +66,14 @@ class AddPreviewFileForm(forms.ModelForm):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save Preview from the cleaned form data."""
|
"""Save Preview from the cleaned form data."""
|
||||||
# If file with this hash was already uploaded by the same user, return it
|
|
||||||
hash_ = self.instance.generate_hash(self.instance.source)
|
|
||||||
model = self.instance.__class__
|
|
||||||
existing_image = model.objects.filter(original_hash=hash_, user=self.request.user).first()
|
|
||||||
if (
|
|
||||||
existing_image
|
|
||||||
and not existing_image.extension_preview.filter(extension_id=self.extension.id).count()
|
|
||||||
):
|
|
||||||
logger.warning('Found an existing %s pk=%s', model, existing_image.pk)
|
|
||||||
self.instance = existing_image
|
|
||||||
|
|
||||||
# Fill in missing fields from request and the source file
|
# Fill in missing fields from request and the source file
|
||||||
self.instance.user = self.request.user
|
self.instance.user = self.request.user
|
||||||
|
|
||||||
instance = super().save(*args, **kwargs)
|
instance = super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Create extension preview and save caption to it
|
# Create extension preview and save caption to it
|
||||||
instance.extension_preview.create(
|
extensions.models.Preview.objects.create(
|
||||||
|
file=instance,
|
||||||
caption=self.cleaned_data['caption'],
|
caption=self.cleaned_data['caption'],
|
||||||
extension=self.extension,
|
extension=self.extension,
|
||||||
)
|
)
|
||||||
|
20
extensions/migrations/0027_unique_preview_files.py
Normal file
20
extensions/migrations/0027_unique_preview_files.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-23 11:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0007_alter_file_status'),
|
||||||
|
('extensions', '0026_remove_extension_date_deleted_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='preview',
|
||||||
|
name='file',
|
||||||
|
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='files.file'),
|
||||||
|
),
|
||||||
|
]
|
@ -277,7 +277,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
TODO: Might be better to query Previews directly instead of going
|
TODO: Might be better to query Previews directly instead of going
|
||||||
for the reverse relationship.
|
for the reverse relationship.
|
||||||
"""
|
"""
|
||||||
return self.previews.listed.order_by('extension_preview__position')
|
return self.previews.listed.order_by('preview__position')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def valid_file_statuses(self) -> List[int]:
|
def valid_file_statuses(self) -> List[int]:
|
||||||
@ -653,9 +653,7 @@ class Maintainer(CreatedModifiedMixin, models.Model):
|
|||||||
|
|
||||||
class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
||||||
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
||||||
file = models.ForeignKey(
|
file = models.OneToOneField('files.File', on_delete=models.CASCADE)
|
||||||
'files.File', related_name='extension_preview', on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
caption = models.CharField(max_length=255, default='', null=False, blank=True)
|
caption = models.CharField(max_length=255, default='', null=False, blank=True)
|
||||||
position = models.IntegerField(default=0)
|
position = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
data-galleria-content-type="{{ preview.content_type }}"
|
data-galleria-content-type="{{ preview.content_type }}"
|
||||||
data-galleria-index="{{ forloop.counter }}">
|
data-galleria-index="{{ forloop.counter }}">
|
||||||
|
|
||||||
<img src="{{ thumbnail_l_url }}" alt="{{ preview.extension_preview.first.caption }}">
|
<img src="{{ thumbnail_l_url }}" alt="{{ preview.preview.caption }}">
|
||||||
</a>
|
</a>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -14,6 +14,7 @@ import reviewers.models
|
|||||||
TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
|
TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
|
||||||
|
|
||||||
|
|
||||||
|
# Media file are physically deleted when files records are deleted, hence the override
|
||||||
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR)
|
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR)
|
||||||
class DeleteTest(TestCase):
|
class DeleteTest(TestCase):
|
||||||
fixtures = ['dev', 'licenses']
|
fixtures = ['dev', 'licenses']
|
||||||
@ -58,7 +59,7 @@ class DeleteTest(TestCase):
|
|||||||
file_validation,
|
file_validation,
|
||||||
extension,
|
extension,
|
||||||
approval_activity,
|
approval_activity,
|
||||||
preview_file.extension_preview.first(),
|
preview_file.preview,
|
||||||
version,
|
version,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -74,7 +74,7 @@ class UpdateTest(TestCase):
|
|||||||
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1)
|
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1)
|
||||||
self.assertEqual(extension.previews.count(), 1)
|
self.assertEqual(extension.previews.count(), 1)
|
||||||
file1 = extension.previews.all()[0]
|
file1 = extension.previews.all()[0]
|
||||||
self.assertEqual(file1.extension_preview.first().caption, 'First Preview Caption Text')
|
self.assertEqual(file1.preview.caption, 'First Preview Caption Text')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
file1.original_hash,
|
file1.original_hash,
|
||||||
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||||
@ -123,8 +123,8 @@ class UpdateTest(TestCase):
|
|||||||
self.assertEqual(extension.previews.count(), 2)
|
self.assertEqual(extension.previews.count(), 2)
|
||||||
file1 = extension.previews.all()[0]
|
file1 = extension.previews.all()[0]
|
||||||
file2 = extension.previews.all()[1]
|
file2 = extension.previews.all()[1]
|
||||||
self.assertEqual(file1.extension_preview.first().caption, 'First Preview Caption Text')
|
self.assertEqual(file1.preview.caption, 'First Preview Caption Text')
|
||||||
self.assertEqual(file2.extension_preview.first().caption, 'Second Preview Caption Text')
|
self.assertEqual(file2.preview.caption, 'Second Preview Caption Text')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
file1.original_hash,
|
file1.original_hash,
|
||||||
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||||
|
@ -180,6 +180,28 @@ def run_clamdscan(abs_path: str) -> tuple:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file_in_storage(file_name: str) -> None:
|
||||||
|
"""Delete file from disk or whatever other default storage."""
|
||||||
|
if not file_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not default_storage.exists(file_name):
|
||||||
|
logger.warning("%s doesn't exist in storage, nothing to delete", file_name)
|
||||||
|
else:
|
||||||
|
logger.info('Deleting %s from storage', file_name)
|
||||||
|
default_storage.delete(file_name)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_thumbnails(file_metadata: dict) -> None:
|
||||||
|
"""Read thumbnail paths from given metadata and delete them from storage."""
|
||||||
|
thumbnails = file_metadata.get('thumbnails', {})
|
||||||
|
for _, thumb in thumbnails.items:
|
||||||
|
path = thumb.get('path', '')
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
delete_file_in_storage(path)
|
||||||
|
|
||||||
|
|
||||||
def get_base_path(file_path: str) -> str:
|
def get_base_path(file_path: str) -> str:
|
||||||
"""Return the given path without its extension."""
|
"""Return the given path without its extension."""
|
||||||
base_path, _ = os.path.splitext(file_path)
|
base_path, _ = os.path.splitext(file_path)
|
||||||
@ -194,8 +216,7 @@ def _resize(image: Image, size: tuple, output, output_format: str = 'PNG', **out
|
|||||||
|
|
||||||
|
|
||||||
def make_thumbnails(image_field, output_paths: dict, output_format: str = 'PNG', **output_params):
|
def make_thumbnails(image_field, output_paths: dict, output_format: str = 'PNG', **output_params):
|
||||||
"""
|
"""Generate thumbnail files for given models.ImageField and list of dimensions.
|
||||||
Generate thumbnail files for given models.ImageField and list of dimensions.
|
|
||||||
|
|
||||||
Currently only intended to be used manually from shell, e.g.:
|
Currently only intended to be used manually from shell, e.g.:
|
||||||
|
|
||||||
@ -262,25 +283,3 @@ def extract_frame(source_path: str, output_path: str):
|
|||||||
except (FFmpegError, FFmpegFileNotFound, FFmpegInvalidCommand) as e:
|
except (FFmpegError, FFmpegFileNotFound, FFmpegInvalidCommand) as e:
|
||||||
logger.exception(f'Failed to extract a frame: {e.message}, {" ".join(ffmpeg.arguments)}')
|
logger.exception(f'Failed to extract a frame: {e.message}, {" ".join(ffmpeg.arguments)}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def delete_file_in_storage(file_name: str) -> None:
|
|
||||||
"""Delete file from disk or whatever other default storage."""
|
|
||||||
if not file_name:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not default_storage.exists(file_name):
|
|
||||||
logger.warning("%s doesn't exist in storage, nothing to delete", file_name)
|
|
||||||
else:
|
|
||||||
logger.info('Deleting %s from storage', file_name)
|
|
||||||
default_storage.delete(file_name)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_thumbnails(file_metadata: dict) -> None:
|
|
||||||
"""Read thumbnail paths from given metadata and delete them from storage."""
|
|
||||||
thumbnails = file_metadata.get('thumbnails', {})
|
|
||||||
for _, thumb in thumbnails.items:
|
|
||||||
path = thumb.get('path', '')
|
|
||||||
if not path:
|
|
||||||
continue
|
|
||||||
delete_file_in_storage(path)
|
|
||||||
|
Loading…
Reference in New Issue
Block a user