Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: New API #9

Draft
wants to merge 43 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
7bc3d6f
Setup automatic publishing via GA
knifecake Dec 11, 2024
f4c491c
Cleanup and start over
knifecake Dec 15, 2024
715029c
Delete .DS_Store
knifecake Dec 15, 2024
78fefd5
More cleanup
knifecake Dec 15, 2024
9ef25dd
Implement field-compatible API for single attachments
knifecake Dec 17, 2024
95a45b9
cleanup and settings
knifecake Dec 18, 2024
7ed49f0
Typo
knifecake Dec 18, 2024
b368aa6
Add filenames to URLs
knifecake Dec 18, 2024
40604d7
properly handle blanks and form fields
knifecake Dec 18, 2024
218f29d
Scaffold variants
knifecake Dec 19, 2024
6346ff7
Compatibility with Python 3.11
knifecake Dec 19, 2024
93638f0
increase test coverage
knifecake Dec 19, 2024
256be94
add variation support
knifecake Dec 20, 2024
bedaf5e
fix runner
knifecake Dec 20, 2024
83e851b
URL services
knifecake Dec 21, 2024
bcd2214
Variant records!
knifecake Dec 21, 2024
ee7cc7b
cleanup
knifecake Dec 22, 2024
52e6cda
add prefetch support
knifecake Dec 22, 2024
a03ada2
bump version
knifecake Dec 22, 2024
6abeb57
fix setuptools
knifecake Dec 22, 2024
d70e783
exclude reverse accessor
knifecake Dec 22, 2024
b000bdc
fix migrations
knifecake Dec 22, 2024
9e8e9f9
Use default storage backend setting
knifecake Dec 22, 2024
dd62af4
handle missing attachments
knifecake Dec 22, 2024
391d69e
handle None in template tags
knifecake Dec 22, 2024
1587547
preprocess transformations
knifecake Dec 22, 2024
387fbd0
Set help text and label in form fields
knifecake Dec 22, 2024
e6e3287
delegate is image to blob
knifecake Dec 22, 2024
c5d8c48
Add custom form fields
knifecake Dec 22, 2024
a8bafe5
skip updating existing attachments
knifecake Dec 22, 2024
b8b76c6
override cache name
knifecake Dec 22, 2024
a87f14e
include templates in distribution
knifecake Dec 22, 2024
e931d3a
rc1
knifecake Dec 22, 2024
50066c1
prevent duplicate variants
knifecake Dec 22, 2024
442875f
handle non-string values for variant_url
knifecake Dec 22, 2024
6fb16b0
improve docs
knifecake Dec 26, 2024
3dad445
clean up upload_to functionality
knifecake Dec 26, 2024
de8fa9d
handle null-ish arguments in template tags
knifecake Dec 27, 2024
44b6ead
use the default backend for admin form
knifecake Dec 27, 2024
516642b
show ID in admin
knifecake Dec 27, 2024
36b7051
upload to from admin
knifecake Dec 27, 2024
2dfcbc3
refactor upload_to
knifecake Dec 27, 2024
a517374
fix upload_to in SingleAttachmentField
knifecake Dec 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
3 changes: 2 additions & 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 Expand Up @@ -72,6 +72,7 @@ jobs:

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/publish_to_pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
on:
release:
types: [published]

jobs:
pypi-publish:
name: upload release to PyPI
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}

- name: Install UV
run: |
python -m pip install uv==0.5.8

- name: Install dependencies
run: |
uv sync

- name: Build package
run: |
uv build

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
Expand All @@ -39,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
}
}
10 changes: 0 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
[![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)


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).
Expand All @@ -29,14 +28,6 @@ Storage](https://edgeguides.rubyonrails.org/active_storage_overview.html).
- 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

## Installation

Django-anchor is compatible with Django >= 4.2 and Python >= 3.10.
Expand All @@ -54,7 +45,6 @@ Django-anchor is compatible with Django >= 4.2 and Python >= 3.10.
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 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, "rc-1")

__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)
20 changes: 20 additions & 0 deletions anchor/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.core.checks import Warning


def test_storage_backends(app_configs, **kwargs):
from django.conf import settings

errors = []
for name, backend in settings.STORAGES.items():
if backend["BACKEND"] == "storages.backends.s3.S3Storage":
if backend.get("OPTIONS", {}).get("file_overwrite", True):
errors.append(
Warning(
"Using S3Storage with file_overwrite=True is not recommended",
hint=f"Set 'file_overwrite' to False in your settings.STORAGES['{name}']['OPTIONS']",
obj=name,
id="anchor.W001",
)
)

return errors
1 change: 1 addition & 0 deletions anchor/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import fields, widgets # noqa
57 changes: 3 additions & 54 deletions anchor/forms/fields.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,7 @@
from django import forms

from anchor.models.blob import Blob
from .widgets import SingleAttachmentInput

from .widgets import ClearableBlobInput


class BlobField(forms.FileField):
"""
A form field for uploading a file and storing it as a Blob.

This field is intended to replace the default Django FileField in forms.
"""

widget = ClearableBlobInput

def __init__(self, *args, **kwargs):
# remove ModelChoiceField attributes from kwargs for compatibility
kwargs.pop("limit_choices_to", None)
kwargs.pop("queryset", None)
kwargs.pop("to_field_name", None)
kwargs.pop("blank", None)
return super().__init__(*args, **kwargs)

def prepare_value(self, value: str):
if value:
try:
blob = Blob.objects.get(pk=value)
return blob.file
except Blob.DoesNotExist:
return None
return value

def clean(self, data, initial=None):
if isinstance(initial, Blob):
initial_file = initial.file
elif isinstance(initial, str):
initial_blob = Blob.objects.filter(pk=initial).first()
if initial_blob:
initial_file = initial_blob.file
else:
initial_file = None
else:
initial_file = None

file = super().clean(data, initial=initial_file)

if file is False:
return None
elif file is None:
if initial is not None:
return Blob.objects.get(pk=initial)
else:
return None
else:
blob = Blob.from_file(file)
blob.save()
return blob
class SingleAttachmentField(forms.FileField):
widget = SingleAttachmentInput
Loading
Loading