Skip to content

Commit

Permalink
v0.6.0
Browse files Browse the repository at this point in the history
  • Loading branch information
knifecake authored Dec 30, 2024
2 parents 52658e8 + e8bd9a6 commit 0691823
Show file tree
Hide file tree
Showing 111 changed files with 3,336 additions and 1,260 deletions.
Binary file removed .DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion .github/workflows/compatibility_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ jobs:
- name: Report coverage
run: |
uv run coverage report -m --skip-covered
uv run coverage report --show-missing --skip-covered --include 'anchor/**'
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ tmp
dist/
*.egg-info
.venv
.env
.DS_Store
14 changes: 14 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"editor.formatOnSave": true,
"files.exclude": {
"**/.DS_Store": true,
"**/__pycache__": true,
".venv": true,
".ruff_cache": true,
"*.egg-info": true,
".coverage": true
}
}
104 changes: 45 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,26 @@
[![Test](https://github.com/knifecake/django-anchor/actions/workflows/test.yml/badge.svg)](https://github.com/knifecake/django-anchor/actions/workflows/test.yml)
[![Documentation Status](https://readthedocs.org/projects/django-anchor/badge/?version=latest)](https://django-anchor.readthedocs.io/en/latest/?badge=latest)

Django Anchor is a reusable Django app that allows you to attach files to models.

A reusable Django app to handle files attached to models, inspired by Ruby on
Rails' excellent [Active
Storage](https://edgeguides.rubyonrails.org/active_storage_overview.html).
Anchor works very similarly to Django's ``FileField`` and ``ImageField`` model
fields, but adds a few extra features:

## Features
- Images can be resized, converted to another format and otherwise transformed.
- Files are served through signed URLs that can expire after a configurable
amount of time, even when using the default file-system storage backend.

- **Attach images and other files to your models**. Supports one or more
individual files per model as well as multiple ordered collections of files.
- **Optimized storage.** Deduplicates files for optimized storage
- **Display files in templates.** Render resized thumbnails and optimized
versions of your images in templates via a template tag.
- **Reduce external dependencies.** Django anchor doesn't need any external
services and works Django's local file storage.

### Limitations

- Files are prefixed with a random string which makes the URLs for them hard to
guess, but they are currently not protected against unauthorized attacks.
- It only works with Django storage classes in which files are accessible via
the file system, i.e. where the
[path](https://docs.djangoproject.com/en/5.0/ref/files/storage/#django.core.files.storage.Storage.path)
property of a file is implemented.
- It currently depends on [Huey](https://huey.readthedocs.io/en/latest/) for
background processing.

### Future work

- [ ] Remove dependency on `base58`
- [ ] Implement private file links (maybe via signed URLs?)
- [ ] Support for async/delayed variant generation
- [ ] Reduce number of dependencies:
- [ ] Make PIL dependency optional
Django Anchor is essentially a port of the excellent `Active Storage
<https://edgeguides.rubyonrails.org/active_storage_overview.html>`_ Ruby on
Rails feature, but leveraging existing Django abstractions and packages of the
Python ecosystem. Some features are not yet implemented, but the core concepts
are there two eventually be able to support them.

## Installation

Django-anchor is compatible with Django >= 4.2 and Python >= 3.10.
Check out the [installation guide](https://django-anchor.readthedocs.io/en/latest/installation.html) in the documentation for more details.

Django-anchor is compatible with Django >= 4.2 and Python >= 3.11.

1. Add the `django-anchor` package to your dependencies. You can do this by
running:
Expand All @@ -51,10 +34,23 @@ Django-anchor is compatible with Django >= 4.2 and Python >= 3.10.

2. Add `anchor` to `settings.INSTALLED_APPS`

3. Add URL configuration to your project:

```python
urlpatterns = [
path('anchor/', include('anchor.urls')),
]
```

4. Run migrations:

```bash
python manage.py migrate
```

In addition, if you wish to create image variants, a Pillow >= 8.4 should be
available in your system.


## Usage

💡 Check out the [demo](./demo/) Django project for inspiration and the [Getting Started guide](https://django-anchor.readthedocs.io/en/latest/getting_started.html) in the documentation.
Expand All @@ -65,40 +61,32 @@ The easiest way to add a file to a model is to add a `BlobField` to it:

```python
from django.db import models
from anchor.models.fields import BlobField
from anchor.models.fields import SingleAttachmentField


class Movie(models.Model):
title = models.CharField(max_length=100)

# A compulsory field that must be set on every instance
cover = BlobField()

# An optional file that can be left blank
poster = BlobField(blank=True, null=True)
cover = SingleAttachmentField()
```

Notice how the `BlobField` above can be customized by setting the `blank` and
`null` options like any other field. It will also accept any other core field
parameters.

BlobFields are ForeignKey fields under the hood, so after you've added or made
changes you need to generate a migration with `python manage.py makemigrations`
and then apply it via `python manage.py migrate`.
That's it! No need to run ``makemigrations`` or ``migrate`` since Django Anchor
doesn't actually need any columns added to the model.

Once your migrations are applied you can assign an
`anchor.models.blob.Blob` object to a `BlobField` much like you'd assign a
`DjangoFile` object to a `FileField`:
The ``cover`` field works just like any other model field:

```python
from anchor.models.blob import Blob
# Create a new movie
movie = Movie.objects.create(title="My Movie")

# Attach an uploaded file
movie.cover = uploaded_file

# A new Blob objects is created and saved to the database with the file metadata
cover = Blob.objects.from_url('...')
# Get a URL to the file
movie.cover.url()

# Make our movie point to that Blob object
movie.cover = cover
movie.save()
# Get a URL to a miniature version of the file
movie.cover.representation(resize_to_fit=(200, 200), format="webp").url()
```

### Using files in templates
Expand All @@ -107,13 +95,11 @@ Django anchor comes with a handy template tag to render URLs of files you've sto

```
{% load anchor %}
<img src="{% blob_thumbnail movie.poster max_width=300 max_height=600 format='jpeg' %}">
<img src="{% variant_url movie.cover resize_to_limit='300x600' format='jpeg' %}">
```

The above call to `blob_thumbnail` will generate an optimized version of the
movie's cover in JPEG format which fits inside a 300x600 rectangle. Optimized
versions are generated asynchronously and if they're not ready for immediate use
the original file's URL is returned instead to avoid blocking the request.
The above call to `variant_url` will generate an optimized version of the
movie's cover in JPEG format which fits inside a 300x600 rectangle.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion anchor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VERSION = (0, 5, 0)
VERSION = (0, 6, 0)

__version__ = ".".join(map(str, VERSION))
140 changes: 89 additions & 51 deletions anchor/admin.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
from django import forms
from django.conf import settings
from django.contrib import admin
from django.contrib.contenttypes.admin import GenericTabularInline
from django.utils.html import format_html
from anchor.models.fields import BlobField
from anchor.forms.widgets import AdminBlobInput
from anchor.models.attachment import Attachment
from anchor.models.blob import Blob
from django.template.defaultfilters import filesizeformat
from django.utils.html import format_html

from anchor.models import Attachment, Blob, VariantRecord
from anchor.settings import anchor_settings


class AdminBlobForm(forms.ModelForm):
class Meta:
model = Blob
fields = []

backend = forms.ChoiceField(
choices=[
(k, k)
for k, v in settings.STORAGES.items()
if v["BACKEND"] != "django.contrib.staticfiles.storage.StaticFilesStorage"
],
initial=anchor_settings.DEFAULT_STORAGE_BACKEND,
)
file = forms.FileField()

def save(self, commit=True):
return Blob.objects.create(
file=self.cleaned_data["file"],
backend=self.cleaned_data["backend"],
key=Blob.key_with_upload_to(
upload_to=anchor_settings.ADMIN_UPLOAD_TO,
instance=None,
file=self.cleaned_data["file"],
),
)

class BlobFieldMixin:
"""
Render a preview of the blob in the admin form.
Inherit from this in your ModelAdmin class to render a preview of the blob
in the admin form.
"""

formfield_overrides = {
BlobField: {"widget": AdminBlobInput},
}
def save_m2m(self):
pass


@admin.register(Attachment)
class AttachmentAdmin(admin.ModelAdmin):
list_display = ("blob", "name", "content_type", "content_object")
list_display = ("blob", "name", "order", "content_type", "object_id")
raw_id_fields = ("blob",)
list_filter = ("content_type",)
search_fields = ("id", "object_id", "blob__id")

def get_queryset(self, request):
return (
Expand All @@ -36,54 +55,73 @@ def get_queryset(self, request):


@admin.register(Blob)
class BlobAdmin(BlobFieldMixin, admin.ModelAdmin):
class BlobAdmin(admin.ModelAdmin):
ordering = ("id",)
date_hierarchy = "created_at"
search_fields = ("filename", "id", "fingerprint", "uploaded_by__email")
list_display = ("filename", "human_size", "uploaded_by", "created_at")
list_filter = ("mime_type",)
readonly_fields = ("filename", "byte_size", "fingerprint", "thumbnail")
raw_id_fields = ("uploaded_by",)

def save_model(self, request, obj, form, change): # pragma: no cover
if not change:
obj.uploaded_by = request.user

super().save_model(request, obj, form, change)
search_fields = ("filename", "id", "checksum")
list_display = ("filename", "human_size", "backend", "created_at")
list_filter = ("backend", "mime_type")
readonly_fields = (
"filename",
"mime_type",
"byte_size",
"human_size",
"checksum",
"preview",
"key",
"id",
"created_at",
)
fieldsets = (
(
None,
{
"fields": (
("key",),
("filename",),
("mime_type", "human_size", "checksum"),
("preview",),
("id", "created_at"),
)
},
),
)

@admin.display(description="Size", ordering="byte_size")
def human_size(self, instance: Blob):
return filesizeformat(instance.byte_size)

def thumbnail(self, instance: Blob):
if instance.file and instance.file.is_image:
def preview(self, instance: Blob):
if instance.is_image:
return format_html(
'<img src="{}" style="max-width: 100%">', instance.file.url
'<img src="{}" style="max-width: calc(min(100%, 450px))">',
instance.url(),
)

return "-"

def get_form(self, request, obj=None, **kwargs):
if obj is None:
return AdminBlobForm

class AttachmentInline(GenericTabularInline):
"""
Inline for Attachment model.
return super().get_form(request, obj, **kwargs)

Add this to the admin.ModelAdmin.inlines attribute of the model you want to attach files to.
"""
def get_readonly_fields(self, request, obj=None):
if obj is None:
return []

model = Attachment
extra = 0
fields = ("blob", "name", "order", "thumbnail")
readonly_fields = ("thumbnail",)
ordering = ("name", "order")
autocomplete_fields = ("blob",)
return super().get_readonly_fields(request, obj)

def thumbnail(self, instance):
if instance.blob:
return format_html(
'<img src="{}" style="max-width: 100%">', instance.blob.file.url
)
def get_fieldsets(self, request, obj=None):
if obj is None:
return [
(None, {"fields": ("backend", "file")}),
]

return super().get_fieldsets(request, obj)

return "-"

thumbnail.short_description = "Thumbnail"
@admin.register(VariantRecord)
class VariantRecordAdmin(admin.ModelAdmin):
list_display = ("blob", "variation_digest")
raw_id_fields = ("blob",)
6 changes: 6 additions & 0 deletions anchor/apps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from django.apps import AppConfig
from django.core.checks import register

from anchor.checks import test_storage_backends


class AnchorConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "anchor"
verbose_name = "Anchor"

def ready(self):
register(test_storage_backends)
Loading

0 comments on commit 0691823

Please sign in to comment.