Skip to content

Commit

Permalink
Variant records!
Browse files Browse the repository at this point in the history
  • Loading branch information
knifecake committed Dec 21, 2024
1 parent 83e851b commit bcd2214
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 19 deletions.
13 changes: 10 additions & 3 deletions anchor/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 "-"
Expand All @@ -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",)
78 changes: 69 additions & 9 deletions anchor/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -117,17 +126,17 @@ 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",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"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")),
(
Expand All @@ -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",
Expand All @@ -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",
)
],
},
Expand Down
3 changes: 2 additions & 1 deletion anchor/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
14 changes: 12 additions & 2 deletions anchor/models/blob/representations.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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:
raise ValueError("Cannot transform non-variable Blob")

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:
Expand All @@ -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
3 changes: 1 addition & 2 deletions anchor/models/fields.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
)
Expand Down
30 changes: 30 additions & 0 deletions anchor/models/variant_record.py
Original file line number Diff line number Diff line change
@@ -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()
41 changes: 41 additions & 0 deletions anchor/models/variant_with_record.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions anchor/models/variation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
5 changes: 5 additions & 0 deletions anchor/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions tests/models/test_variation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Expand Down

0 comments on commit bcd2214

Please sign in to comment.