From bcd2214fdb5fecba85e55551410a5b10cbe12af4 Mon Sep 17 00:00:00 2001 From: Elias Hernandis <elias@hernandis.me> Date: Sat, 21 Dec 2024 13:34:46 +0100 Subject: [PATCH] Variant records! --- anchor/admin.py | 13 +++-- anchor/migrations/0001_initial.py | 78 +++++++++++++++++++++++---- anchor/models/__init__.py | 3 +- anchor/models/blob/representations.py | 14 ++++- anchor/models/fields.py | 3 +- anchor/models/variant_record.py | 30 +++++++++++ anchor/models/variant_with_record.py | 41 ++++++++++++++ anchor/models/variation.py | 1 + anchor/settings.py | 5 ++ tests/models/test_variation.py | 7 ++- 10 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 anchor/models/variant_record.py create mode 100644 anchor/models/variant_with_record.py diff --git a/anchor/admin.py b/anchor/admin.py index 84e6df6..5975703 100644 --- a/anchor/admin.py +++ b/anchor/admin.py @@ -4,7 +4,7 @@ from django.template.defaultfilters import filesizeformat from django.utils.html import format_html -from anchor.models import Attachment, Blob +from anchor.models import Attachment, Blob, VariantRecord class AdminBlobForm(forms.ModelForm): @@ -87,9 +87,10 @@ def human_size(self, instance: Blob): return filesizeformat(instance.byte_size) def preview(self, instance: Blob): - if instance.is_image and instance.url: + if instance.is_image: return format_html( - '<img src="{}" style="max-width: calc(min(100%, 450px))">', instance.url + '<img src="{}" style="max-width: calc(min(100%, 450px))">', + instance.url(), ) return "-" @@ -113,3 +114,9 @@ def get_fieldsets(self, request, obj=None): ] return super().get_fieldsets(request, obj) + + +@admin.register(VariantRecord) +class VariantRecordAdmin(admin.ModelAdmin): + list_display = ("blob", "variation_digest") + raw_id_fields = ("blob",) diff --git a/anchor/migrations/0001_initial.py b/anchor/migrations/0001_initial.py index 98c9e4a..8912153 100644 --- a/anchor/migrations/0001_initial.py +++ b/anchor/migrations/0001_initial.py @@ -1,9 +1,12 @@ -# Generated by Django 5.1.4 on 2024-12-15 11:38 +# Generated by Django 5.1.4 on 2024-12-21 12:34 -import anchor.models.base import django.db.models.deletion +import django.utils.timezone from django.db import migrations, models +import anchor.models.base +import anchor.models.blob.representations + class Migration(migrations.Migration): initial = True @@ -29,7 +32,9 @@ class Migration(migrations.Migration): ), ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="created at"), + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="created at" + ), ), ( "updated_at", @@ -100,6 +105,10 @@ class Migration(migrations.Migration): "verbose_name": "blob", "verbose_name_plural": "blobs", }, + bases=( + anchor.models.blob.representations.RepresentationsMixin, + models.Model, + ), ), migrations.CreateModel( name="Attachment", @@ -117,7 +126,9 @@ class Migration(migrations.Migration): ), ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="created at"), + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="created at" + ), ), ( "updated_at", @@ -125,9 +136,7 @@ class Migration(migrations.Migration): ), ( "object_id", - models.CharField( - db_index=True, max_length=22, verbose_name="object id" - ), + models.CharField(max_length=64, verbose_name="object id"), ), ("order", models.IntegerField(default=0, verbose_name="order")), ( @@ -139,6 +148,7 @@ class Migration(migrations.Migration): ( "content_type", models.ForeignKey( + db_index=False, on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype", verbose_name="content type", @@ -159,8 +169,58 @@ class Migration(migrations.Migration): options={ "constraints": [ models.UniqueConstraint( - fields=("content_type", "object_id", "blob", "name"), - name="unique_attachment_per_blob_and_object_and_name", + fields=("content_type", "object_id", "name", "order"), + name="unique_attachment_per_content_type_and_object_and_name_and_order", + ) + ], + }, + ), + migrations.CreateModel( + name="VariantRecord", + fields=[ + ( + "id", + models.CharField( + default=anchor.models.base._gen_short_uuid, + editable=False, + max_length=22, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="created at" + ), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ( + "variation_digest", + models.CharField(max_length=32, verbose_name="variation digest"), + ), + ( + "blob", + models.ForeignKey( + db_index=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="variant_records", + to="anchor.blob", + verbose_name="blob", + ), + ), + ], + options={ + "verbose_name": "variant record", + "verbose_name_plural": "variant records", + "indexes": [ + models.Index( + fields=["blob", "variation_digest"], + name="ix_anchor_records_blob_digest", ) ], }, diff --git a/anchor/models/__init__.py b/anchor/models/__init__.py index 743e715..5543fc2 100644 --- a/anchor/models/__init__.py +++ b/anchor/models/__init__.py @@ -2,5 +2,6 @@ from .blob.blob import Blob from .fields import SingleAttachmentField from .variant import Variant +from .variant_record import VariantRecord -__all__ = ["Blob", "Attachment", "SingleAttachmentField", "Variant"] +__all__ = ["Blob", "Attachment", "SingleAttachmentField", "Variant", "VariantRecord"] diff --git a/anchor/models/blob/representations.py b/anchor/models/blob/representations.py index ca3429d..1c3541e 100644 --- a/anchor/models/blob/representations.py +++ b/anchor/models/blob/representations.py @@ -1,9 +1,10 @@ from typing import Any +from anchor.settings import anchor_settings + class RepresentationsMixin: def variant(self, transformations: dict[str, Any]): - from anchor.models.variant import Variant from anchor.models.variation import Variation if not self.is_variable: @@ -11,7 +12,7 @@ def variant(self, transformations: dict[str, Any]): variation = Variation.wrap(transformations) variation.default_to(self.default_variant_transformations) - return Variant(self, variation) + return self.variant_class(self, variation) @property def is_variable(self) -> bool: @@ -30,3 +31,12 @@ def is_representable(self) -> bool: @property def default_variant_transformations(self) -> dict[str, Any]: return {"format": "webp"} + + @property + def variant_class(self): + from anchor.models.variant import Variant + from anchor.models.variant_with_record import VariantWithRecord + + if anchor_settings.TRACK_VARIANTS: + return VariantWithRecord + return Variant diff --git a/anchor/models/fields.py b/anchor/models/fields.py index c39b9c4..80c0fb4 100644 --- a/anchor/models/fields.py +++ b/anchor/models/fields.py @@ -1,6 +1,5 @@ from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType -from django.core.files.base import File from django.db import models from django.db.models import Model @@ -35,7 +34,7 @@ def __set__(self, instance, value): if isinstance(value, Blob): blob = value - elif isinstance(value, File): + elif hasattr(value, "read"): # quacks like a file? blob = Blob.objects.create( file=value, backend=self.backend, prefix=self.prefix ) diff --git a/anchor/models/variant_record.py b/anchor/models/variant_record.py new file mode 100644 index 0000000..6919063 --- /dev/null +++ b/anchor/models/variant_record.py @@ -0,0 +1,30 @@ +from django.db import models + +from anchor.models.base import BaseModel +from anchor.models.fields import SingleAttachmentField + + +class VariantRecord(BaseModel): + class Meta: + verbose_name = "variant record" + verbose_name_plural = "variant records" + indexes = ( + models.Index( + fields=["blob", "variation_digest"], + name="ix_anchor_records_blob_digest", + ), + ) + + blob = models.ForeignKey( + "anchor.Blob", + on_delete=models.CASCADE, + related_name="variant_records", + db_index=False, + verbose_name="blob", + ) + variation_digest = models.CharField(max_length=32, verbose_name="variation digest") + image = SingleAttachmentField() + + def delete(self): + self.image.delete() + super().delete() diff --git a/anchor/models/variant_with_record.py b/anchor/models/variant_with_record.py new file mode 100644 index 0000000..6d86ca4 --- /dev/null +++ b/anchor/models/variant_with_record.py @@ -0,0 +1,41 @@ +from contextlib import contextmanager + +from django.core.files import File + +from anchor.models.variant import Variant +from anchor.models.variant_record import VariantRecord + + +class VariantWithRecord(Variant): + @property + def is_processed(self) -> bool: + return self.record is not None + + def image(self): + return self.record.image if self.record else None + + def delete(self): + if self.record: + self.record.delete() + + super().delete() + + @property + def record(self) -> VariantRecord: + return VariantRecord.objects.filter( + blob=self.blob, variation_digest=self.variation.digest + ).first() + + def get_or_create_record(self, image: File) -> VariantRecord: + record = VariantRecord.objects.get_or_create( + blob=self.blob, variation_digest=self.variation.digest + )[0] + record.image = image + return record + + @contextmanager + def process(self): + with super().process() as image: + self.get_or_create_record(image) + image.seek(0) + yield image diff --git a/anchor/models/variation.py b/anchor/models/variation.py index 4855daf..07aeadb 100644 --- a/anchor/models/variation.py +++ b/anchor/models/variation.py @@ -33,6 +33,7 @@ def default_to(self, default_transformations: dict[str, Any]) -> None: def key(self) -> str: return type(self).encode(self.transformations) + @property def digest(self) -> str: m = hashlib.sha1() m.update(json.dumps(self.transformations).encode("utf-8")) diff --git a/anchor/settings.py b/anchor/settings.py index 0494e64..626cfcc 100644 --- a/anchor/settings.py +++ b/anchor/settings.py @@ -29,6 +29,11 @@ class AnchorSettings: The image processor to use for image transformations. """ + TRACK_VARIANTS: bool = True + """ + Store variant records in the database. + """ + def __getattribute__(self, name: str) -> Any: user_settings = getattr(settings, ANCHOR_SETTINGS_NAME, {}) return user_settings.get(name, super().__getattribute__(name)) diff --git a/tests/models/test_variation.py b/tests/models/test_variation.py index 66a3968..94818aa 100644 --- a/tests/models/test_variation.py +++ b/tests/models/test_variation.py @@ -29,12 +29,15 @@ def test_encoding(self): def test_transformation_order_matters_for_digests(self): t1 = {"format": "png", "resize_to_fit": [100, 200]} - d1 = Variation(t1).digest() + d1 = Variation(t1).digest t2 = {"resize_to_fit": [100, 200], "format": "png"} - d2 = Variation(t2).digest() + d2 = Variation(t2).digest self.assertNotEqual(d1, d2) + def test_digest_length(self): + self.assertEqual(len(Variation({}).digest), 28) + @override_settings(SECRET_KEY="test") def test_keys_are_stable(self): t1 = {"format": "png", "resize_to_fit": [100, 200]}