diff --git a/anchor/admin.py b/anchor/admin.py index dcec232..1e52e20 100644 --- a/anchor/admin.py +++ b/anchor/admin.py @@ -21,19 +21,12 @@ class Meta: initial="default", ) file = forms.FileField() - prefix = forms.CharField( - required=False, help_text="The folder where to store the blob into" - ) def save(self, commit=True): - blob = Blob( - prefix=self.cleaned_data["prefix"], backend=self.cleaned_data["backend"] + return Blob.objects.create( + file=self.cleaned_data["file"], backend=self.cleaned_data["backend"] ) - blob.upload(self.cleaned_data["file"]) - blob.save() - return blob - def save_m2m(self): pass @@ -112,7 +105,7 @@ def get_readonly_fields(self, request, obj=None): def get_fieldsets(self, request, obj=None): if obj is None: return [ - (None, {"fields": ("backend", "file", "prefix")}), + (None, {"fields": ("backend", "file")}), ] return super().get_fieldsets(request, obj) diff --git a/anchor/models/blob/blob.py b/anchor/models/blob/blob.py index ac5d30e..8bde035 100644 --- a/anchor/models/blob/blob.py +++ b/anchor/models/blob/blob.py @@ -4,7 +4,7 @@ import mimetypes import os from secrets import token_bytes -from typing import Any, Callable, Optional, Self +from typing import Any, Optional from django.core.files import File as DjangoFile from django.core.files.storage import Storage, storages @@ -140,26 +140,16 @@ class Meta: If you need to store custom metadata, refer to the :py:attr:`custom_metadata` property. """ - upload_to: str | Callable[[Self], str] | None = None - - def __init__(self, *args, upload_to=None, backend=None, **kwargs): + def __init__(self, *args, backend=None, **kwargs): super().__init__(*args, **kwargs) if self.key == "": - self.key = self.generate_key() + self.key = type(self).generate_key() if backend is not None: self.backend = backend else: self.backend = anchor_settings.DEFAULT_STORAGE_BACKEND - if upload_to is not None: - self.upload_to = upload_to - - if isinstance(upload_to, str): - self.prefix = upload_to - elif callable(upload_to): - self.prefix = upload_to(self) - @property def signed_id(self): """ @@ -220,17 +210,6 @@ def unsign_id(cls, signed_id: str, purpose: str = None): def _get_signer(cls): return AnchorSigner() - @property - def prefix(self): - return os.path.dirname(self.key) - - @prefix.setter - def prefix(self, value): - if value is None: - self.key = os.path.basename(self.key) - else: - self.key = os.path.join(value, os.path.basename(self.key)) - def __str__(self): return self.filename or self.id @@ -292,7 +271,8 @@ def storage(self) -> Storage: """ return storages.create_storage(storages.backends[self.backend]) - def generate_key(self): + @classmethod + def generate_key(cls): """ Generates a random key to store this blob in the storage backend. @@ -303,7 +283,7 @@ def generate_key(self): case insensitive file systems. """ return ( - base64.b32encode(token_bytes(self.KEY_LENGTH)) + base64.b32encode(token_bytes(cls.KEY_LENGTH)) .decode("utf-8") .replace("=", "") .lower() diff --git a/anchor/models/blob/representations.py b/anchor/models/blob/representations.py index b682903..9bf5fd2 100644 --- a/anchor/models/blob/representations.py +++ b/anchor/models/blob/representations.py @@ -42,8 +42,10 @@ def representation(self, transformations: dict[str, Any]): """ if self.is_variable: return self.variant(transformations) - else: + elif self.is_previewable: return self.preview() + else: + raise NotRepresentableError() @property def is_representable(self) -> bool: diff --git a/anchor/models/fields/single_attachment.py b/anchor/models/fields/single_attachment.py index 2b263cb..18ebed9 100644 --- a/anchor/models/fields/single_attachment.py +++ b/anchor/models/fields/single_attachment.py @@ -1,10 +1,12 @@ -from typing import Callable +import os +from typing import Any, Callable from django.contrib.contenttypes.fields import GenericRel, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Model from django.db.models.fields.related_descriptors import ReverseOneToOneDescriptor +from django.utils import timezone from django.utils.functional import cached_property from django.utils.text import capfirst @@ -41,7 +43,7 @@ def __init__( self, related: GenericRel, name: str, - upload_to: str | Callable[[Blob], str] = None, + upload_to: str | Callable[[models.Model, Any], str] = None, backend: str = None, ): self.related = related @@ -80,7 +82,7 @@ def __set__(self, instance, value): blob = value elif hasattr(value, "read"): # quacks like a file? blob = Blob.objects.create( - file=value, backend=self.backend, upload_to=self.upload_to + file=value, backend=self.backend, key=self.get_key(value) ) else: raise ValueError( @@ -88,10 +90,6 @@ def __set__(self, instance, value): ) Attachment.objects.update_or_create( - # object_id=instance.id, - # content_type=ContentType.objects.get_for_model(instance), - # name=self.name, - # order=0, **self.related.field.get_forward_related_filter(instance), defaults={"blob": blob}, ) @@ -140,6 +138,17 @@ def instance_attr(i): False, ) + def get_key(self, value: Any): + if self.upload_to is None: + return Blob.generate_key() + elif isinstance(self.upload_to, str): + dir = timezone.now().strftime(str(self.upload_to)) + return os.path.join(dir, Blob.generate_key()) + elif callable(self.upload_to): + return self.upload_to(self.model, value) + else: + raise ValueError("upload_to must be a string or a callable") + class SingleAttachmentField(GenericRelation): """ @@ -175,7 +184,7 @@ class SingleAttachmentField(GenericRelation): def __init__( self, - upload_to: str | Callable[[Blob], str] = None, + upload_to: str | Callable[[models.Model, Blob], str] = None, backend: str = None, **kwargs, ): diff --git a/tests/models/blob/test_blob.py b/tests/models/blob/test_blob.py index f28b9de..70303cf 100644 --- a/tests/models/blob/test_blob.py +++ b/tests/models/blob/test_blob.py @@ -89,16 +89,6 @@ def test_is_image(self): class TestBlobKeys(SimpleTestCase): - def test_upload_to_with_no_upload_to(self): - blob = Blob() - self.assertIsNone(blob.upload_to) - - def test_upload_to_can_be_set(self): - blob = Blob(upload_to="test") - self.assertEqual(blob.upload_to, "test") - self.assertTrue(blob.key.startswith("test/")) - self.assertTrue(len(blob.key) > len(blob.upload_to)) - def test_key_is_generated(self): blob = Blob() self.assertIsNotNone(blob.key) @@ -108,10 +98,6 @@ def test_key_can_be_set(self): blob = Blob(key="test") self.assertEqual(blob.key, "test") - def test_key_and_upload_to_can_be_set(self): - blob = Blob(key="test", upload_to="test2") - self.assertEqual(blob.key, "test2/test") - def test_str(self): blob = Blob(key="test") self.assertEqual(str(blob), blob.pk) @@ -122,11 +108,6 @@ def test_upload_file(self): blob = Blob() blob.upload(File(BytesIO(b"test"), name="text.txt")) - def test_upload_file_with_upload_to(self): - blob = Blob(upload_to="test") - blob.upload(File(BytesIO(b"test"), name="text.txt")) - self.assertTrue(blob.key.startswith("test/")) - @skipUnless("r2-dev" in settings.STORAGES, "R2 is not configured") def test_upload_file_to_r2(self): blob = Blob(backend="r2-dev") @@ -134,14 +115,6 @@ def test_upload_file_to_r2(self): self.assertIsNotNone(blob.key) self.assertTrue(blob.storage.exists(blob.key)) - @skipUnless("r2-dev" in settings.STORAGES, "R2 is not configured") - def test_upload_image_to_r2(self): - blob = Blob(backend="r2-dev", upload_to="test", key="image.png") - with open(GARLIC_PNG, mode="rb") as f: - blob.upload(File(f, name="image.png")) - self.assertTrue(blob.key.startswith("test/")) - self.assertTrue(blob.storage.exists(blob.key)) - def test_open(self): blob = Blob() blob.upload(ContentFile(b"test", name="test.txt"))