From 3a79d3f2e868038bee3c5a83a1d0e9ed1bd0615b Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 3 Jun 2024 18:51:15 +0200 Subject: [PATCH 1/9] Draft: display icon and featured image in the same order as on Edit page --- .../templates/extensions/draft_finalise.html | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/extensions/templates/extensions/draft_finalise.html b/extensions/templates/extensions/draft_finalise.html index 220113d8..1b63be16 100644 --- a/extensions/templates/extensions/draft_finalise.html +++ b/extensions/templates/extensions/draft_finalise.html @@ -49,22 +49,20 @@
-

{% trans 'Media' %}

-
- {# TODO: @web-assets check media brakpoints utilities 'md' #} -
-
- {% trans "Featured Image" as featured_image_label %} - {% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %} - {% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %} -
-
-
-
+

{% trans 'Featured image and icon' %}

+
+
+
{% trans "Icon" as icon_label %} {% trans "A 256 x 256 PNG icon representing this extension." as icon_help_text %} {% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
+ +
+ {% trans "Featured image" as featured_image_label %} + {% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %} + {% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %} +
-- 2.30.2 From 12bda6c418af58c4ee5be5c5391a36a6d48cb1b0 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 3 Jun 2024 18:54:00 +0200 Subject: [PATCH 2/9] Files can be reused between extensions/previews/icon/featured --- common/admin.py | 13 +++-- common/tests/factories/extensions.py | 5 +- extensions/forms.py | 45 ++++++++---------- ...red_image_alter_extension_icon_and_more.py | 30 ++++++++++++ ...red_image_alter_extension_icon_and_more.py | 30 ++++++++++++ extensions/models.py | 12 +++-- extensions/signals.py | 29 +++++------ extensions/tests/test_manifest.py | 34 ++++--------- extensions/tests/test_submit.py | 20 ++++---- extensions/views/submit.py | 1 - files/admin.py | 13 +++-- files/forms.py | 40 ++++++++-------- ...file_extension_alter_file_hash_and_more.py | 27 +++++++++++ files/models.py | 12 +---- notifications/tests/test_follow_logic.py | 2 +- ...5f232b7cacc4f1eb3e3c3d851615841d2956e1.png | Bin 0 -> 29098 bytes 16 files changed, 190 insertions(+), 123 deletions(-) create mode 100644 extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py create mode 100644 extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py create mode 100644 files/migrations/0010_remove_file_extension_alter_file_hash_and_more.py create mode 100644 public/media/images/8a/8a01102de8573d50bbc90033f55f232b7cacc4f1eb3e3c3d851615841d2956e1.png diff --git a/common/admin.py b/common/admin.py index e8ea2544..0423bcd3 100644 --- a/common/admin.py +++ b/common/admin.py @@ -65,10 +65,17 @@ def link_to(field_name, title=None): @admin.display(description=title, ordering=field_name) def _raw(obj): + target_obj = getattr(obj, field_name) + if isinstance(target_obj, models.Manager): + admin_urls = [] + for _obj in target_obj.all(): + if related_field_name: + _obj = getattr(_obj, related_field_name) + admin_urls.append(get_admin_change_url(_obj)) + return format_html('
'.join(admin_urls)) + if related_field_name: - target_obj = getattr(getattr(obj, field_name), related_field_name) - else: - target_obj = getattr(obj, field_name) + target_obj = getattr(target_obj, related_field_name) admin_url = get_admin_change_url(target_obj) return admin_url diff --git a/common/tests/factories/extensions.py b/common/tests/factories/extensions.py index 6ef2ed75..f4618437 100644 --- a/common/tests/factories/extensions.py +++ b/common/tests/factories/extensions.py @@ -108,10 +108,7 @@ class VersionFactory(DjangoModelFactory): def create_version(**kwargs) -> 'Version': version = VersionFactory(**kwargs) - file = version.file - file.extension = version.extension - file.save(update_fields={'extension'}) - file.extension.authors.add(version.file.user) + version.extension.authors.add(version.file.user) return version diff --git a/extensions/forms.py b/extensions/forms.py index 6bb7dd3f..fed86ad5 100644 --- a/extensions/forms.py +++ b/extensions/forms.py @@ -65,17 +65,22 @@ class AddPreviewFileForm(files.forms.BaseMediaFileForm): instance = super().save(*args, **kwargs) # Create extension preview and save caption to it - extensions.models.Preview.objects.create( - file=instance, - caption=self.cleaned_data['caption'], - extension=self.extension, + extensions.models.Preview.objects.bulk_create( + [ + extensions.models.Preview( + file=instance, + caption=self.cleaned_data['caption'], + extension=self.extension, + ) + ], + ignore_conflicts=True, + update_conflicts=False, ) + return instance class AddPreviewModelFormSet(forms.BaseModelFormSet): - msg_duplicate_file = _('Please select another file instead of the duplicate') - def __init__(self, *args, **kwargs): self.request = kwargs.pop('request') self.extension = kwargs.pop('extension') @@ -89,14 +94,6 @@ class AddPreviewModelFormSet(forms.BaseModelFormSet): form_kwargs['extension'] = self.extension return form_kwargs - def get_unique_error_message(self, unique_check): - """Replace duplicate `original_hash`/`hash` message with a more meaningful one.""" - if len(unique_check) == 1: - field = unique_check[0] - if field in ('original_hash', 'hash'): - return self.msg_duplicate_file - return super().get_unique_error_message(unique_check) - AddPreviewFormSet = forms.modelformset_factory( files.models.File, @@ -116,7 +113,6 @@ class ExtensionUpdateForm(forms.ModelForm): 'An extension can be converted to draft only while it is Awating Review' ) msg_need_previews = _('Please add at least one preview.') - msg_duplicate_file = _('Please select another file instead of the duplicate.') class Meta: model = extensions.models.Extension @@ -198,16 +194,17 @@ class ExtensionUpdateForm(forms.ModelForm): self.featured_image_form, self.icon_form, ] - seen_hashes = set() + seen_hashes = {} + for f in new_file_forms: + hash = f.instance.original_hash + if hash and hash not in seen_hashes: + seen_hashes[hash] = f.instance + # Ignore duplicate files in the formsets: point all forms sharing the hash to the same + # file instance, when forms call .save() it'll be on the same instance. for f in new_file_forms: hash = f.instance.original_hash if hash: - if hash in seen_hashes: - f.add_error('source', self.msg_duplicate_file) - is_valid_flags.append(False) - break - seen_hashes.add(hash) - + f.instance = seen_hashes[hash] return all(is_valid_flags) def clean_team(self): @@ -352,9 +349,9 @@ class IconForm(files.forms.BaseMediaFileForm): } expected_size_px = 256 - def clean_source(self): + def clean_source(self, *args, **kwargs): """Check image resolution.""" - source = self.cleaned_data.get('source') + source = super().clean_source(*args, **kwargs) if not source: return image = getattr(source, 'image', None) diff --git a/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py b/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py new file mode 100644 index 00000000..e3bd58de --- /dev/null +++ b/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2024-06-03 15:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0010_remove_file_extension_alter_file_hash_and_more'), + ('extensions', '0032_extension_extensions__is_list_765936_idx_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='extension', + name='featured_image', + field=models.ForeignKey(help_text='Shown by social networks when this extension is shared (used as `og:image` metadata field).Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_image_of', to='files.file'), + ), + migrations.AlterField( + model_name='extension', + name='icon', + field=models.ForeignKey(help_text='A 256 x 256 PNG icon representing this extension.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='icon_of', to='files.file'), + ), + migrations.AlterField( + model_name='preview', + name='file', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='files.file'), + ), + ] diff --git a/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py b/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py new file mode 100644 index 00000000..2b0d0cfc --- /dev/null +++ b/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2024-06-03 16:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0010_remove_file_extension_alter_file_hash_and_more'), + ('extensions', '0033_alter_extension_featured_image_alter_extension_icon_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='extension', + name='featured_image', + field=models.ForeignKey(help_text='Shown by social networks when this extension is shared (used as `og:image` metadata field).Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='featured_image_of', to='files.file'), + ), + migrations.AlterField( + model_name='extension', + name='icon', + field=models.ForeignKey(help_text='A 256 x 256 PNG icon representing this extension.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='icon_of', to='files.file'), + ), + migrations.AlterField( + model_name='preview', + name='file', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='files.file'), + ), + ] diff --git a/extensions/models.py b/extensions/models.py index 8e2b7968..8c604325 100644 --- a/extensions/models.py +++ b/extensions/models.py @@ -178,24 +178,24 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod related_name='latest_version_of', ) - featured_image = models.OneToOneField( + featured_image = models.ForeignKey( 'files.File', related_name='featured_image_of', null=True, blank=False, - on_delete=models.SET_NULL, + on_delete=models.PROTECT, help_text=( "Shown by social networks when this extension is shared" " (used as `og:image` metadata field)." "Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9." ), ) - icon = models.OneToOneField( + icon = models.ForeignKey( 'files.File', related_name='icon_of', null=True, blank=False, - on_delete=models.SET_NULL, + on_delete=models.PROTECT, help_text="A 256 x 256 PNG icon representing this extension.", ) previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions') @@ -748,12 +748,14 @@ class Maintainer(CreatedModifiedMixin, models.Model): class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model): extension = models.ForeignKey(Extension, on_delete=models.CASCADE) - file = models.OneToOneField('files.File', on_delete=models.CASCADE) + # Files can potentially be referenced by different extensions + file = models.ForeignKey('files.File', on_delete=models.PROTECT) caption = models.CharField(max_length=255, default='', null=False, blank=True) position = models.IntegerField(default=0) class Meta: ordering = ('position', 'date_created') + # We don't want to have duplicate previews on the same extension unique_together = [['extension', 'file']] @property diff --git a/extensions/signals.py b/extensions/signals.py index a25ac85b..4d0bb37f 100644 --- a/extensions/signals.py +++ b/extensions/signals.py @@ -5,7 +5,7 @@ from actstream.actions import follow, unfollow from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db import transaction -from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete +from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete, post_delete from django.dispatch import receiver from constants.activity import Flag @@ -16,22 +16,6 @@ logger = logging.getLogger(__name__) User = get_user_model() -@receiver(pre_save, sender=extensions.models.Preview) -def _set_extension( - sender: object, instance: extensions.models.Preview, raw: bool, **kwargs: object -) -> None: - if raw: - return - - file = instance.file - if not file: - return - - if not file.extension_id: - file.extension_id = instance.extension_id - file.save(update_fields={'extension_id'}) - - @receiver(pre_delete, sender=extensions.models.Extension) @receiver(pre_delete, sender=extensions.models.Preview) @receiver(pre_delete, sender=extensions.models.Version) @@ -45,6 +29,17 @@ def _log_deletion( instance.record_deletion() +@receiver(post_delete, sender=extensions.models.Version) +def _delete_version_file( + sender: object, instance: extensions.models.Version, **kwargs: object +) -> None: + # **N.B.**: this isn't part of an overloaded `Version.delete()` method because + # that method isn't called when `Extension.delete()` cascades to deleting the versions. + version_file = instance.file + logger.info('Deleting file pk=%s of Version pk=%s', version_file.pk, instance.pk) + version_file.delete() + + @receiver(pre_save, sender=extensions.models.Extension) def _record_changes( sender: object, diff --git a/extensions/tests/test_manifest.py b/extensions/tests/test_manifest.py index f48e718d..431a1e3d 100644 --- a/extensions/tests/test_manifest.py +++ b/extensions/tests/test_manifest.py @@ -1,5 +1,5 @@ from django.test import TestCase -from django.urls import reverse +from django.urls import reverse_lazy from django.core.exceptions import ValidationError import factory @@ -35,6 +35,8 @@ META_DATA = { class CreateFileTest(TestCase): + submit_url = reverse_lazy('extensions:submit') + def setUp(self): super().setUp() @@ -52,10 +54,6 @@ class CreateFileTest(TestCase): super().tearDown() shutil.rmtree(self.temp_directory) - @classmethod - def _get_submit_url(cls): - return reverse('extensions:submit') - def _create_valid_extension(self, extension_id): return create_approved_version( extension__name='Blender Kitsu', @@ -120,9 +118,7 @@ class ValidateManifestTest(CreateFileTest): bad_file = self._create_file_from_data("theme.zip", file_data, self.user) with open(bad_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 200) error = response.context['form'].errors.get('source')[0] @@ -140,9 +136,7 @@ class ValidateManifestTest(CreateFileTest): bad_file = self._create_file_from_data("theme.zip", file_data, self.user) with open(bad_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 200) error = response.context['form'].errors.get('source')[0] @@ -165,9 +159,7 @@ class ValidateManifestTest(CreateFileTest): extension_file = self._create_file_from_data("theme.zip", kitsu_1_5, self.user) with open(extension_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 200) error = response.context['form'].errors.get('source')[0] @@ -191,9 +183,7 @@ class ValidateManifestTest(CreateFileTest): extension_file = self._create_file_from_data("theme.zip", kitsu_1_5, self.user) with open(extension_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 200) error = response.context['form'].errors.get('source')[0] @@ -317,9 +307,7 @@ class ValidateManifestTest(CreateFileTest): bad_file = self._create_file_from_data("theme.zip", file_data, self.user) with open(bad_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 200) error = response.context['form'].errors.get('source') @@ -338,13 +326,11 @@ class ValidateManifestTest(CreateFileTest): extension_file = self._create_file_from_data("theme.zip", file_data, self.user) with open(extension_file, 'rb') as fp: - response = self.client.post( - self._get_submit_url(), {'source': fp, 'agreed_with_terms': True} - ) + response = self.client.post(self.submit_url, {'source': fp, 'agreed_with_terms': True}) self.assertEqual(response.status_code, 302) file = File.objects.first() - extension = file.extension + extension = file.version.extension self.assertEqual(extension.slug, 'an-id') self.assertEqual(extension.name, 'Name. - With Extra spaces and other characters Ж') diff --git a/extensions/tests/test_submit.py b/extensions/tests/test_submit.py index e081b266..3cc6cbe1 100644 --- a/extensions/tests/test_submit.py +++ b/extensions/tests/test_submit.py @@ -137,8 +137,8 @@ class SubmitFileTest(TestCase): self.assertEqual(response.status_code, 302) self.assertEqual(File.objects.count(), 1) file = File.objects.first() - self.assertEqual(response['Location'], file.get_submit_url()) - extension = file.extension + self.assertEqual(response['Location'], file.version.extension.get_draft_url()) + extension = file.version.extension self.assertEqual(extension.slug, slug) self.assertEqual(extension.name, name) self.assertEqual(file.original_name, file_name) @@ -203,8 +203,7 @@ class SubmitFileTest(TestCase): self.assertEqual(response.status_code, 302, _get_all_form_errors(response)) self.assertEqual(File.objects.count(), 1) file = File.objects.first() - self.assertIsNotNone(file.extension_id) - self.assertEqual(response['Location'], file.get_submit_url()) + self.assertEqual(response['Location'], file.version.extension.get_draft_url()) self.assertEqual(file.user, user) self.assertEqual(file.original_name, 'theme.zip') self.assertEqual(file.size_bytes, 5895) @@ -285,18 +284,19 @@ class SubmitFinaliseTest(CheckFilePropertiesMixin, TestCase): ) def test_get_finalise_addon_redirects_if_anonymous(self): - response = self.client.post(self.file.get_submit_url(), {}) + response = self.client.post(self.file.version.extension.get_draft_url(), {}) self.assertEqual(response.status_code, 302) self.assertEqual( - response['Location'], f'/oauth/login?next=/add-ons/{self.file.extension.slug}/draft/' + response['Location'], + f'/oauth/login?next=/add-ons/{self.file.version.extension.slug}/draft/', ) def test_get_finalise_addon_not_allowed_if_different_user(self): user = UserFactory() self.client.force_login(user) - response = self.client.post(self.file.get_submit_url(), {}) + response = self.client.post(self.file.version.extension.get_draft_url(), {}) # Technically this could (should) be a 403, but changing this means changing # the MaintainedExtensionMixin which is used in multiple places. @@ -305,7 +305,7 @@ class SubmitFinaliseTest(CheckFilePropertiesMixin, TestCase): def test_post_finalise_addon_validation_errors(self): self.client.force_login(self.file.user) data = {**POST_DATA, 'submit_draft': ''} - response = self.client.post(self.file.get_submit_url(), data) + response = self.client.post(self.file.version.extension.get_draft_url(), data) self.assertEqual(response.status_code, 200) self.assertDictEqual( @@ -371,7 +371,9 @@ class SubmitFinaliseTest(CheckFilePropertiesMixin, TestCase): 'icon-source': fp3, 'featured-image-source': fp4, } - response = self.client.post(self.file.get_submit_url(), {**data, **files}) + response = self.client.post( + self.file.version.extension.get_draft_url(), {**data, **files} + ) self.assertEqual(response.status_code, 302, _get_all_form_errors(response)) self.assertEqual(response['Location'], '/add-ons/edit-breakdown/manage/') diff --git a/extensions/views/submit.py b/extensions/views/submit.py index 648c0f10..a1e22d58 100644 --- a/extensions/views/submit.py +++ b/extensions/views/submit.py @@ -64,7 +64,6 @@ class UploadFileView(LoginRequiredMixin, CreateView): # Need to save the form to be able to use the file to create the version. self.object = self.file = form.save() - self.file.extension = self.extension Version.objects.update_or_create( extension=self.extension, file=self.file, **self.file.parsed_version_fields )[0] diff --git a/files/admin.py b/files/admin.py index 8f34264f..6914ea74 100644 --- a/files/admin.py +++ b/files/admin.py @@ -41,6 +41,10 @@ class FileAdmin(admin.ModelAdmin): class Media: css = {'all': ('files/admin/file.css',)} + def get_queryset(self, *args, **kwargs): + q = super().get_queryset(*args, **kwargs) + return q.prefetch_related(*self.list_prefetch_related) + def thumbnails(self, obj): if not obj or not (obj.is_image or obj.is_video): return '' @@ -72,7 +76,6 @@ class FileAdmin(admin.ModelAdmin): 'date_created', 'date_modified', 'date_status_changed', - ('extension', admin.EmptyFieldListFilter), ('version', admin.EmptyFieldListFilter), ('icon_of', admin.EmptyFieldListFilter), ('featured_image_of', admin.EmptyFieldListFilter), @@ -80,7 +83,6 @@ class FileAdmin(admin.ModelAdmin): ) list_display = ( 'original_name', - link_to('extension'), link_to('user'), 'date_created', 'type', @@ -88,19 +90,20 @@ class FileAdmin(admin.ModelAdmin): link_to('version'), link_to('icon_of'), link_to('featured_image_of'), - link_to('preview.extension', 'preview of'), + link_to('preview_set.extension', 'preview of'), 'is_ok', ) list_select_related = ( 'version__extension', 'user', - 'extension', 'version', 'validation', + ) + list_prefetch_related = ( 'icon_of', 'featured_image_of', - 'preview__extension', + 'preview_set__extension', ) autocomplete_fields = ['user'] diff --git a/files/forms.py b/files/forms.py index ae1be39d..c49c9fdd 100644 --- a/files/forms.py +++ b/files/forms.py @@ -6,7 +6,6 @@ import tempfile from django import forms from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -import django.core.exceptions from .validators import ( ExtensionIDManifestValidator, @@ -44,7 +43,7 @@ class FileForm(forms.ModelForm): class Meta: model = files.models.File - fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user', 'extension') + fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user') source = forms.FileField( allow_empty_file=False, @@ -123,7 +122,6 @@ class FileForm(forms.ModelForm): 'size_bytes': source.size, 'original_hash': hash_, 'hash': hash_, - 'extension': self.extension, } ) @@ -180,8 +178,7 @@ class FileFormSkipAgreed(FileForm): class BaseMediaFileForm(forms.ModelForm): class Meta: model = files.models.File - fields = ('source', 'original_hash') - widgets = {'original_hash': forms.HiddenInput()} + fields = ('source',) source = forms.ImageField(widget=forms.FileInput, allow_empty_file=False) @@ -210,22 +207,23 @@ class BaseMediaFileForm(forms.ModelForm): super().__init__(*args, **kwargs) - self.instance.user = self.request.user - - def clean_original_hash(self, *args, **kwargs): - """Calculate original hash of the uploaded file.""" + def clean_source(self, *args, **kwargs): + """Calculate original hash of the uploaded file, reuse existing record matching it.""" source = self.cleaned_data.get('source') if 'source' in self.changed_data and source: - return files.models.File.generate_hash(source) - - def add_error(self, field, error): - """Add hidden `original_hash` errors to the visible `source` field instead.""" - if isinstance(error, django.core.exceptions.ValidationError): - if getattr(error, 'error_dict', None): - hash_error = error.error_dict.pop('original_hash', None) - if hash_error: - error.error_dict['source'] = hash_error - super(forms.ModelForm, self).add_error(field, error) + original_hash = files.models.File.generate_hash(source) + instance = files.models.File.objects.filter(original_hash=original_hash).first() + if instance: + # File with this hash exists already, make sure it's reused here + if instance.pk != self.instance.pk: + self.instance = instance + else: + previous_hash = self.instance.hash + if previous_hash and original_hash != previous_hash and self.instance.pk: + # Create a new file instead of changing the existing one + self.instance.pk = None + self.instance.original_hash = original_hash + return source def save(self, *args, **kwargs): """Save as `to_field` on the parent object (Extension).""" @@ -235,7 +233,9 @@ class BaseMediaFileForm(forms.ModelForm): self.instance.original_name = source.name self.instance.size_bytes = source.size - self.instance.extension = self.extension + if not self.instance.user_id: + self.instance.user = self.request.user + instance = super().save(*args, **kwargs) if hasattr(self, 'to_field'): diff --git a/files/migrations/0010_remove_file_extension_alter_file_hash_and_more.py b/files/migrations/0010_remove_file_extension_alter_file_hash_and_more.py new file mode 100644 index 00000000..4881544f --- /dev/null +++ b/files/migrations/0010_remove_file_extension_alter_file_hash_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.11 on 2024-06-03 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0009_file_extension'), + ] + + operations = [ + migrations.RemoveField( + model_name='file', + name='extension', + ), + migrations.AlterField( + model_name='file', + name='hash', + field=models.CharField(blank=True, editable=False, max_length=255, unique=True), + ), + migrations.AlterField( + model_name='file', + name='original_hash', + field=models.CharField(blank=True, editable=False, help_text='The original hash of the file before we repackage it any way.', max_length=255, unique=True), + ), + ] diff --git a/files/models.py b/files/models.py index eaa60699..0878a353 100644 --- a/files/models.py +++ b/files/models.py @@ -49,12 +49,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model): date_approved = models.DateTimeField(null=True, blank=True, editable=False) date_status_changed = models.DateTimeField(null=True, blank=True, editable=False) - extension = models.ForeignKey( - 'extensions.Extension', - null=True, - blank=True, - on_delete=models.CASCADE, - ) source = models.FileField(null=False, blank=False, upload_to=file_upload_to) thumbnail = models.ImageField( @@ -82,12 +76,13 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model): User, related_name='files', null=False, blank=False, on_delete=models.CASCADE ) size_bytes = models.PositiveBigIntegerField(default=0, editable=False) - hash = models.CharField(max_length=255, null=False, blank=True, unique=True) + hash = models.CharField(max_length=255, null=False, blank=True, unique=True, editable=False) original_name = models.CharField(max_length=255, blank=True, null=False) original_hash = models.CharField( max_length=255, null=False, blank=True, + editable=False, unique=True, help_text='The original hash of the file before we repackage it any way.', ) @@ -196,9 +191,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model): 'tags': data.get('tags'), } - def get_submit_url(self) -> str: - return self.extension.get_draft_url() - def get_thumbnail_of_size(self, size_key: str) -> str: """Return absolute path portion of the URL of a thumbnail of this file. diff --git a/notifications/tests/test_follow_logic.py b/notifications/tests/test_follow_logic.py index 7b2ed498..5028caf9 100644 --- a/notifications/tests/test_follow_logic.py +++ b/notifications/tests/test_follow_logic.py @@ -159,7 +159,7 @@ class TestTasks(TestCase): 'form-0-source': fp1, 'form-1-source': fp2, } - self.client.post(file.get_submit_url(), {**data, **files}) + self.client.post(file.version.extension.get_draft_url(), {**data, **files}) new_notification_nr = Notification.objects.filter(recipient=moderator).count() self.assertEqual(new_notification_nr, notification_nr + 1) diff --git a/public/media/images/8a/8a01102de8573d50bbc90033f55f232b7cacc4f1eb3e3c3d851615841d2956e1.png b/public/media/images/8a/8a01102de8573d50bbc90033f55f232b7cacc4f1eb3e3c3d851615841d2956e1.png new file mode 100644 index 0000000000000000000000000000000000000000..4d312310cb8eee2595fa73053cf04e266711e33d GIT binary patch literal 29098 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 literal 0 HcmV?d00001 -- 2.30.2 From aa79172b50fb5f40935da87494ab54aff07acb5f Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 3 Jun 2024 19:24:01 +0200 Subject: [PATCH 3/9] Update tests --- extensions/tests/test_delete.py | 35 ++++++++--- extensions/tests/test_update.py | 106 +++++++++++++++++++++----------- 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/extensions/tests/test_delete.py b/extensions/tests/test_delete.py index f19b2e6b..d6612bcf 100644 --- a/extensions/tests/test_delete.py +++ b/extensions/tests/test_delete.py @@ -21,23 +21,38 @@ class DeleteTest(TestCase): def test_unlisted_unrated_extension_can_be_deleted_by_author(self): self.maxDiff = None + reused_image = FileFactory( + type=files.models.File.TYPES.IMAGE, + original_name='extension_feature_image.png', + source='images/b0/b03fa981527593fbe15b28cf37c020220c3d83021999eab036b87f3bca9c9168.png', + ) version = create_version( file__status=files.models.File.STATUSES.AWAITING_REVIEW, ratings=[], + extension__icon=FileFactory( + type=files.models.File.TYPES.IMAGE, + original_name='extension_icon_final.png', + source='images/8a/8a01102de8573d50bbc90033f55f232b7cacc4f1eb3e3c3d851615841d2956e1.png', + ), + extension__featured_image=reused_image, extension__previews=[ + reused_image, FileFactory( type=files.models.File.TYPES.IMAGE, + original_name='extension_preview_001.png', source='images/b0/b03fa981527593fbe15b28cf37c020220c3d83021999eab036b87f3bca9c9168.png', - ) + ), ], ) extension = version.extension version_file = version.file + icon = extension.icon + featured_image = extension.featured_image self.assertEqual(version_file.get_status_display(), 'Awaiting Review') self.assertEqual(extension.get_status_display(), 'Draft') self.assertFalse(extension.is_listed) self.assertEqual(extension.cannot_be_deleted_reasons, []) - preview_file = extension.previews.first() + preview_file = extension.previews.last() self.assertIsNotNone(preview_file) # Create some ApprovalActivity as well moderator = create_moderator() @@ -55,11 +70,11 @@ class DeleteTest(TestCase): repr, [ version_file, - preview_file, + file_validation, extension, approval_activity, - file_validation, - preview_file.preview, + preview_file.preview_set.first(), + reused_image.preview_set.first(), version, ], ) @@ -78,12 +93,16 @@ class DeleteTest(TestCase): version.refresh_from_db() with self.assertRaises(files.models.File.DoesNotExist): version_file.refresh_from_db() - with self.assertRaises(files.models.File.DoesNotExist): - preview_file.refresh_from_db() + # Preview files aren't deleted: they might be re-uploaded shortly and should be looked up by hash + preview_file.refresh_from_db() + icon.refresh_from_db() + featured_image.refresh_from_db() self.assertIsNone(extensions.models.Extension.objects.filter(pk=extension.pk).first()) self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first()) self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first()) - self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first()) + self.assertIsNotNone(files.models.File.objects.filter(pk=preview_file.pk).first()) + self.assertIsNotNone(files.models.File.objects.filter(pk=icon.pk).first()) + self.assertIsNotNone(files.models.File.objects.filter(pk=featured_image.pk).first()) # Check that each of the deleted records was logged deletion_log_entries_q = LogEntry.objects.filter(action_flag=DELETION) diff --git a/extensions/tests/test_update.py b/extensions/tests/test_update.py index b5b15fc6..67df8e33 100644 --- a/extensions/tests/test_update.py +++ b/extensions/tests/test_update.py @@ -81,7 +81,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1) self.assertEqual(extension.previews.count(), 1) file1 = extension.previews.all()[0] - self.assertEqual(file1.preview.caption, 'First Preview Caption Text') + self.assertEqual(file1.preview_set.first().caption, 'First Preview Caption Text') self.assertEqual( file1.original_hash, 'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', @@ -125,7 +125,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): extension.refresh_from_db() self.assertEqual(extension.previews.count(), 1) video_file = extension.previews.all()[0] - self.assertEqual(video_file.preview.caption, 'First Preview Caption Text') + self.assertEqual(video_file.preview_set.first().caption, 'First Preview Caption Text') self._test_file_properties( video_file, content_type='video/mp4', @@ -169,8 +169,8 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): self.assertEqual(extension.previews.count(), 2) file1 = extension.previews.all()[0] file2 = extension.previews.all()[1] - self.assertEqual(file1.preview.caption, 'First Preview Caption Text') - self.assertEqual(file2.preview.caption, 'Second Preview Caption Text') + self.assertEqual(file1.preview_set.first().caption, 'First Preview Caption Text') + self.assertEqual(file2.preview_set.first().caption, 'Second Preview Caption Text') self.assertEqual( file1.original_hash, 'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', @@ -222,8 +222,10 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): _get_all_form_errors(response), ) - def test_post_upload_validation_error_duplicate_images(self): + def test_post_upload_duplicate_preview_files_ignored(self): extension = create_approved_version().extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertEqual(extension.previews.count(), 0) data = { **POST_DATA, @@ -243,31 +245,23 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): } response = self.client.post(url, {**data, **files}) - self.assertEqual(response.status_code, 200) - self.maxDiff = None + self.assertEqual(response.status_code, 302) + # One image was uploaded successfully self.assertEqual( - [ - response.context['add_preview_formset'].forms[0].errors, - response.context['add_preview_formset'].forms[1].errors, - response.context['add_preview_formset'].non_form_errors(), - ], - [ - {}, - { - '__all__': ['Please correct the duplicate values below.'], - 'source': ['Please select another file instead of the duplicate.'], - }, - ['Please select another file instead of the duplicate'], - ], + File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before + 1 ) + self.assertEqual(extension.previews.count(), 1) - def test_post_upload_validation_error_image_already_exists(self): - FileFactory( + def test_post_upload_existing_image_file_linked_to_extension(self): + file = FileFactory( + type=File.TYPES.IMAGE, original_hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', source='file/original_image_source.jpg', ) extension = create_approved_version().extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertEqual(extension.previews.count(), 0) data = { **POST_DATA, @@ -281,15 +275,51 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): files = {'form-0-source': fp1} response = self.client.post(url, {**data, **files}) - self.assertEqual(response.status_code, 200) - self.maxDiff = None - self.assertEqual( - response.context['add_preview_formset'].forms[0].errors, - {'source': ['File with this Original hash already exists.']}, - ) + self.assertEqual(response.status_code, 302) + # No new files were created: the existing one was linked to the extension instead + self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before) + self.assertEqual(extension.previews.count(), 1) + self.assertEqual(extension.previews.first().original_hash, file.original_hash) + self.assertEqual(extension.previews.first().pk, file.pk) - def test_post_upload_validation_error_duplicate_across_forms(self): + def test_post_upload_existing_preview_file_linked_to_extension(self): + file = FileFactory( + type=File.TYPES.IMAGE, + original_hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', + hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', + source='file/original_image_source.jpg', + ) extension = create_approved_version().extension + another_extension = create_approved_version(extension__previews=[file]).extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertEqual(extension.previews.count(), 0) + self.assertEqual(another_extension.previews.count(), 1) + + data = { + **POST_DATA, + 'form-TOTAL_FORMS': ['1'], + } + file_name1 = 'test_preview_image_0001.png' + url = extension.get_manage_url() + user = extension.authors.first() + self.client.force_login(user) + with open(TEST_FILES_DIR / file_name1, 'rb') as fp1: + files = {'form-0-source': fp1} + response = self.client.post(url, {**data, **files}) + + self.assertEqual(response.status_code, 302) + # No new files were created: the existing one was linked to the extension instead + self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before) + self.assertEqual(extension.previews.count(), 1) + self.assertEqual(extension.previews.first().original_hash, file.original_hash) + self.assertEqual(extension.previews.first().pk, file.pk) + # File is referenced as a preview by both extensions + self.assertEqual(file.preview_set.count(), 2) + + def test_post_upload_duplicates_are_ignored_across_forms(self): + extension = create_approved_version().extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertEqual(extension.previews.count(), 0) data = { **POST_DATA, @@ -304,12 +334,12 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): files = {'form-0-source': fp, 'icon-source': fp1} response = self.client.post(url, {**data, **files}) - self.assertEqual(response.status_code, 200) - self.maxDiff = None + self.assertEqual(response.status_code, 302) + # One image was uploaded successfully self.assertEqual( - response.context['icon_form'].errors, - {'source': ['Please select another file instead of the duplicate.']}, + File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before + 1 ) + self.assertEqual(extension.previews.count(), 1) def test_post_upload_validation_error_unexpected_preview_format_gif(self): extension = create_approved_version().extension @@ -419,6 +449,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): original_name='old_icon.png', size_bytes=1234, ) + old_icon = extension.icon self.client.force_login(extension.authors.first()) url = extension.get_manage_url() @@ -427,7 +458,9 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): response = self.client.post(url, {**POST_DATA, **files}) self.assertEqual(response.status_code, 302) - extension.icon.refresh_from_db() + old_icon.refresh_from_db() + extension.refresh_from_db() + self.assertNotEqual(extension.icon_id, old_icon.pk) self._test_file_properties( extension.icon, content_type='image/png', @@ -456,6 +489,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): original_name='old_featured_image.png', size_bytes=1234, ) + old_featured_image = extension.featured_image self.client.force_login(extension.authors.first()) url = extension.get_manage_url() @@ -464,7 +498,9 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): response = self.client.post(url, {**POST_DATA, **files}) self.assertEqual(response.status_code, 302) - extension.featured_image.refresh_from_db() + old_featured_image.refresh_from_db() + extension.refresh_from_db() + self.assertNotEqual(extension.featured_image_id, old_featured_image.pk) self._test_file_properties( extension.featured_image, content_type='image/png', -- 2.30.2 From 1254adba35d973d2c6aa629371fd601b7dd38091 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 3 Jun 2024 19:33:19 +0200 Subject: [PATCH 4/9] Test that user doesn't change on re-upload --- extensions/tests/test_update.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extensions/tests/test_update.py b/extensions/tests/test_update.py index 67df8e33..c2b6b792 100644 --- a/extensions/tests/test_update.py +++ b/extensions/tests/test_update.py @@ -262,6 +262,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): extension = create_approved_version().extension images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() self.assertEqual(extension.previews.count(), 0) + old_user = file.user data = { **POST_DATA, @@ -281,6 +282,8 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): self.assertEqual(extension.previews.count(), 1) self.assertEqual(extension.previews.first().original_hash, file.original_hash) self.assertEqual(extension.previews.first().pk, file.pk) + file.refresh_from_db() + self.assertEqual(file.user, old_user) def test_post_upload_existing_preview_file_linked_to_extension(self): file = FileFactory( @@ -294,6 +297,7 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() self.assertEqual(extension.previews.count(), 0) self.assertEqual(another_extension.previews.count(), 1) + old_user = file.user data = { **POST_DATA, @@ -315,6 +319,8 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): self.assertEqual(extension.previews.first().pk, file.pk) # File is referenced as a preview by both extensions self.assertEqual(file.preview_set.count(), 2) + file.refresh_from_db() + self.assertEqual(file.user, old_user) def test_post_upload_duplicates_are_ignored_across_forms(self): extension = create_approved_version().extension -- 2.30.2 From 347cc0c8521abcce3f0b5b5c3a4a40f455b24be9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 3 Jun 2024 20:00:52 +0200 Subject: [PATCH 5/9] Test reusing an icon file --- extensions/tests/test_update.py | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/extensions/tests/test_update.py b/extensions/tests/test_update.py index c2b6b792..55be4ead 100644 --- a/extensions/tests/test_update.py +++ b/extensions/tests/test_update.py @@ -479,6 +479,68 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase): size_bytes=30177, ) + def test_update_icon_existing_file_linked(self): + file = FileFactory( + type=File.TYPES.IMAGE, + original_hash='sha256:ee3a015c51e35a237755713ec578334efa9ed8870af65b708f591f9254ff4472', + hash='sha256:ee3a015c51e35a237755713ec578334efa9ed8870af65b708f591f9254ff4472', + source='test_icon_0001.png', + ) + extension = create_approved_version().extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertIsNone(extension.icon) + self.assertEqual(file.icon_of.count(), 0) + old_user = file.user + + url = extension.get_manage_url() + user = extension.authors.first() + self.client.force_login(user) + with open(TEST_FILES_DIR / 'test_icon_0001.png', 'rb') as fp: + files = {'icon-source': fp} + response = self.client.post(url, {**POST_DATA, **files}) + + self.assertEqual(response.status_code, 302) + # No new files were created: the existing one was linked to the extension instead + self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before) + extension.refresh_from_db() + self.assertEqual(extension.icon, file) + self.assertEqual(file.icon_of.count(), 1) + file.refresh_from_db() + self.assertEqual(file.user, old_user) + + def test_update_icon_existing_file_linked_to_multiple_extensions(self): + file = FileFactory( + type=File.TYPES.IMAGE, + original_hash='sha256:ee3a015c51e35a237755713ec578334efa9ed8870af65b708f591f9254ff4472', + hash='sha256:ee3a015c51e35a237755713ec578334efa9ed8870af65b708f591f9254ff4472', + source='test_icon_0001.png', + ) + extension = create_approved_version().extension + another_extension = create_approved_version(extension__icon=file).extension + images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count() + self.assertIsNone(extension.icon) + self.assertEqual(another_extension.icon_id, file.pk) + old_user = file.user + + url = extension.get_manage_url() + user = extension.authors.first() + self.client.force_login(user) + with open(TEST_FILES_DIR / 'test_icon_0001.png', 'rb') as fp: + files = {'icon-source': fp} + response = self.client.post(url, {**POST_DATA, **files}) + + self.assertEqual(response.status_code, 302) + # No new files were created: the existing one was linked to the extension instead + self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before) + extension.refresh_from_db() + another_extension.refresh_from_db() + self.assertEqual(extension.icon, file) + self.assertEqual(another_extension.icon, file) + # File is referenced as a preview by both extensions + self.assertEqual(file.icon_of.count(), 2) + file.refresh_from_db() + self.assertEqual(file.user, old_user) + def test_update_featured_image_changes_expected_file_fields(self): extension = create_approved_version( extension__featured_image=ImageFactory( -- 2.30.2 From 72bad9d36d1fa20760936e7d7694e30d15908ae9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 4 Jun 2024 10:58:32 +0200 Subject: [PATCH 6/9] Merge migrations --- ...red_image_alter_extension_icon_and_more.py | 30 ------------------- ...red_image_alter_extension_icon_and_more.py | 4 +-- 2 files changed, 2 insertions(+), 32 deletions(-) delete mode 100644 extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py diff --git a/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py b/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py deleted file mode 100644 index e3bd58de..00000000 --- a/extensions/migrations/0033_alter_extension_featured_image_alter_extension_icon_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 4.2.11 on 2024-06-03 15:05 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('files', '0010_remove_file_extension_alter_file_hash_and_more'), - ('extensions', '0032_extension_extensions__is_list_765936_idx_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='extension', - name='featured_image', - field=models.ForeignKey(help_text='Shown by social networks when this extension is shared (used as `og:image` metadata field).Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_image_of', to='files.file'), - ), - migrations.AlterField( - model_name='extension', - name='icon', - field=models.ForeignKey(help_text='A 256 x 256 PNG icon representing this extension.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='icon_of', to='files.file'), - ), - migrations.AlterField( - model_name='preview', - name='file', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='files.file'), - ), - ] diff --git a/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py b/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py index 2b0d0cfc..f509c282 100644 --- a/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py +++ b/extensions/migrations/0034_alter_extension_featured_image_alter_extension_icon_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-03 16:38 +# Generated by Django 4.2.11 on 2024-06-04 08:54 from django.db import migrations, models import django.db.models.deletion @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('files', '0010_remove_file_extension_alter_file_hash_and_more'), - ('extensions', '0033_alter_extension_featured_image_alter_extension_icon_and_more'), + ('extensions', '0033_extensions_fts_20240603_1918'), ] operations = [ -- 2.30.2 From a3fa05c35582d4beded8094ed7b231845dbc6149 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 4 Jun 2024 11:04:02 +0200 Subject: [PATCH 7/9] Use get_or_create when saving Previews --- extensions/forms.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/extensions/forms.py b/extensions/forms.py index fed86ad5..6cb5a2e0 100644 --- a/extensions/forms.py +++ b/extensions/forms.py @@ -64,19 +64,12 @@ class AddPreviewFileForm(files.forms.BaseMediaFileForm): """Save Preview from the cleaned form data.""" instance = super().save(*args, **kwargs) - # Create extension preview and save caption to it - extensions.models.Preview.objects.bulk_create( - [ - extensions.models.Preview( - file=instance, - caption=self.cleaned_data['caption'], - extension=self.extension, - ) - ], - ignore_conflicts=True, - update_conflicts=False, + # Create extension preview and save caption to it, ignore duplicate records + extensions.models.Preview.objects.get_or_create( + file=instance, + extension=self.extension, + defaults={'caption': self.cleaned_data['caption']}, ) - return instance -- 2.30.2 From f1ca1d4f917184bfe61f5d2eb2ac4cad7b0abef7 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 4 Jun 2024 11:12:14 +0200 Subject: [PATCH 8/9] Single loop for deduplication --- extensions/forms.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/forms.py b/extensions/forms.py index 6cb5a2e0..f6aed00a 100644 --- a/extensions/forms.py +++ b/extensions/forms.py @@ -188,16 +188,16 @@ class ExtensionUpdateForm(forms.ModelForm): self.icon_form, ] seen_hashes = {} - for f in new_file_forms: - hash = f.instance.original_hash - if hash and hash not in seen_hashes: - seen_hashes[hash] = f.instance # Ignore duplicate files in the formsets: point all forms sharing the hash to the same # file instance, when forms call .save() it'll be on the same instance. for f in new_file_forms: hash = f.instance.original_hash - if hash: + if not hash: + continue + if hash in seen_hashes: f.instance = seen_hashes[hash] + else: + seen_hashes[hash] = f.instance return all(is_valid_flags) def clean_team(self): -- 2.30.2 From 7235956988147b5ab2cbe1f512d7477b20aaa743 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 4 Jun 2024 11:12:23 +0200 Subject: [PATCH 9/9] Reference docs --- extensions/signals.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/signals.py b/extensions/signals.py index 4d0bb37f..cb94f041 100644 --- a/extensions/signals.py +++ b/extensions/signals.py @@ -34,7 +34,10 @@ def _delete_version_file( sender: object, instance: extensions.models.Version, **kwargs: object ) -> None: # **N.B.**: this isn't part of an overloaded `Version.delete()` method because - # that method isn't called when `Extension.delete()` cascades to deleting the versions. + # that method isn't called when `Extension.delete()` cascades to deleting the versions: + # + # delete() method for an object is not necessarily called ... as a result of a cascading delete + # https://docs.djangoproject.com/en/4.2/topics/db/models/#overriding-predefined-model-methods version_file = instance.file logger.info('Deleting file pk=%s of Version pk=%s', version_file.pk, instance.pk) version_file.delete() -- 2.30.2