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]}