Skip to content

Commit 17da737

Browse files
authored
Add delete_orphans keyword in favor of delete signals (#210)
1 parent 29f5e6a commit 17da737

File tree

7 files changed

+148
-71
lines changed

7 files changed

+148
-71
lines changed

README.md

+17-12
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class MyModel(models.Model):
6767
'large': (600, 400),
6868
'thumbnail': (100, 100, True),
6969
'medium': (300, 200),
70+
delete_orphans=True,
7071
})
7172
```
7273

@@ -108,26 +109,30 @@ As storage isn't expensive, you shouldn't restrict upload dimensions.
108109
If you seek prevent users form overflowing your memory you should restrict the HTTP upload body size.
109110

110111
### Deleting images
112+
111113
Django [dropped support](https://docs.djangoproject.com/en/dev/releases/1.3/#deleting-a-model-doesn-t-delete-associated-files)
112114
for automated deletions in version 1.3.
113-
Implementing file deletion [should be done](http://stackoverflow.com/questions/5372934/how-do-i-get-django-admin-to-delete-files-when-i-remove-an-object-from-the-data)
114-
inside your own applications using the `post_delete` or `pre_delete` signal.
115-
Clearing the field if blank is true, does not delete the file. This can also be achieved using `pre_save` and `post_save` signals.
116-
This packages contains two signal callback methods that handle file deletion for all SdtImageFields of a model.
117115

118-
```python
119-
from django.db.models.signals import pre_delete, pre_save
120-
from stdimage.utils import pre_delete_delete_callback, pre_save_delete_callback
116+
Since version 5, this package supports a `delete_orphans` argument. It will delete
117+
orphaned files, should a file be delete or replaced via Django form or and object with
118+
a `StdImageField` be deleted. It will not be deleted if the field value is changed or
119+
reassigned programatically. In those rare cases, you will need to handle proper deletion
120+
yourself.
121121

122-
from . import models
122+
```python
123+
from django.db import models
124+
from stdimage.models import StdImageField
123125

124126

125-
pre_delete.connect(pre_delete_delete_callback, sender=models.MyModel)
126-
pre_save.connect(pre_save_delete_callback, sender=models.MyModel)
127+
class MyModel(models.Model):
128+
image = StdImageField(
129+
upload_to='path/to/files',
130+
variations={'thumbnail': (100, 75)},
131+
delete_orphans=True,
132+
blank=True,
133+
)
127134
```
128135

129-
**Warning:** You should not use the signal callbacks in production. They may result in data loss.
130-
131136
### Async image processing
132137
Tools like celery allow to execute time-consuming tasks outside of the request. If you don't want
133138
to wait for your variations to be rendered in request, StdImage provides your the option to pass a

stdimage/models.py

+48-23
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,15 @@ class StdImageField(ImageField):
153153
Django ImageField that is able to create different size variations.
154154
155155
Extra features are:
156-
- Django-Storages compatible (S3)
157-
- Python 2, 3 and PyPy support
158-
- Django 1.5 and later support
159-
- Resize images to different sizes
160-
- Access thumbnails on model level, no template tags required
161-
- Preserves original image
162-
- Asynchronous rendering (Celery & Co)
163-
- Multi threading and processing for optimum performance
164-
- Restrict accepted image dimensions
165-
- Rename files to a standardized name (using a callable upload_to)
166-
167-
:param variations: size variations of the image
156+
157+
- Django-Storages compatible (S3)
158+
- Access thumbnails on model level, no template tags required
159+
- Preserves original image
160+
- Asynchronous rendering (Celery & Co)
161+
- Multi threading and processing for optimum performance
162+
- Restrict accepted image dimensions
163+
- Rename files to a standardized name (using a callable upload_to)
164+
168165
"""
169166

170167
descriptor_class = StdImageFileDescriptor
@@ -177,19 +174,34 @@ class StdImageField(ImageField):
177174
}
178175

179176
def __init__(self, verbose_name=None, name=None, variations=None,
180-
render_variations=True, force_min_size=False,
181-
*args, **kwargs):
177+
render_variations=True, force_min_size=False, delete_orphans=False,
178+
**kwargs):
182179
"""
183180
Standardized ImageField for Django.
184181
185-
Usage: StdImageField(upload_to='PATH',
186-
variations={'thumbnail': {"width", "height", "crop", "resample"}})
187-
:param variations: size variations of the image
188-
:rtype variations: StdImageField
189-
:param render_variations: boolean or callable that returns a boolean.
190-
The callable gets passed the app_name, model, field_name and pk.
191-
Default: True
192-
:rtype render_variations: bool, callable
182+
Usage::
183+
184+
StdImageField(
185+
upload_to='PATH',
186+
variations={
187+
'thumbnail': {"width", "height", "crop", "resample"},
188+
},
189+
delete_orphans=True,
190+
)
191+
192+
Args:
193+
variations (dict):
194+
Different size variations of the image.
195+
render_variations (bool, callable):
196+
Boolean or callable that returns a boolean. If True, the built-in
197+
image render will be used. The callable gets passed the ``app_name``,
198+
``model``, ``field_name`` and ``pk``. Default: ``True``
199+
delete_orphans (bool):
200+
If ``True``, files orphaned files will be removed in case a new file
201+
is assigned or the field is cleared. This will only remove work for
202+
Django forms. If you unassign or reassign a field in code, you will
203+
need to remove the orphaned files yourself.
204+
193205
"""
194206
if not variations:
195207
variations = {}
@@ -207,6 +219,7 @@ def __init__(self, verbose_name=None, name=None, variations=None,
207219
self.force_min_size = force_min_size
208220
self.render_variations = render_variations
209221
self.variations = {}
222+
self.delete_orphans = delete_orphans
210223

211224
for nm, prm in list(variations.items()):
212225
self.add_variation(nm, prm)
@@ -219,7 +232,7 @@ def __init__(self, verbose_name=None, name=None, variations=None,
219232
key=lambda x: x["height"])["height"]
220233
)
221234

222-
super().__init__(verbose_name, name, *args, **kwargs)
235+
super().__init__(verbose_name=verbose_name, name=name, **kwargs)
223236

224237
def add_variation(self, name, params):
225238
variation = self.def_variation.copy()
@@ -253,12 +266,24 @@ def set_variations(self, instance=None, **kwargs):
253266
variation_name)
254267
setattr(field, name, variation_field)
255268

269+
def post_delete_callback(self, sender, instance, **kwargs):
270+
getattr(instance, self.name).delete(False)
271+
256272
def contribute_to_class(self, cls, name):
257273
"""Generate all operations on specified signals."""
258274
super().contribute_to_class(cls, name)
259275
signals.post_init.connect(self.set_variations, sender=cls)
276+
if self.delete_orphans:
277+
signals.post_delete.connect(self.post_delete_callback, sender=cls)
260278

261279
def validate(self, value, model_instance):
262280
super().validate(value, model_instance)
263281
if self.force_min_size:
264282
MinSizeValidator(self.min_size[0], self.min_size[1])(value)
283+
284+
def save_form_data(self, instance, data):
285+
if self.delete_orphans and self.blank and (data is False or data is not None):
286+
file = getattr(instance, self.name)
287+
if file and file._committed and file != data:
288+
file.delete(save=False)
289+
super().save_form_data(instance, data)

stdimage/utils.py

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,6 @@
11
from django.core.files.storage import default_storage
22

3-
from .models import StdImageField, StdImageFieldFile
4-
5-
6-
def pre_delete_delete_callback(sender, instance, **kwargs):
7-
for field in instance._meta.fields:
8-
if isinstance(field, StdImageField):
9-
getattr(instance, field.name).delete(False)
10-
11-
12-
def pre_save_delete_callback(sender, instance, **kwargs):
13-
if instance.pk:
14-
obj = sender.objects.get(pk=instance.pk)
15-
for field in instance._meta.fields:
16-
if isinstance(field, StdImageField):
17-
obj_field = getattr(obj, field.name)
18-
instance_field = getattr(instance, field.name)
19-
if obj_field and obj_field != instance_field:
20-
obj_field.delete(False)
3+
from .models import StdImageFieldFile
214

225

236
def render_variations(file_name, variations, replace=False,

tests/forms.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django import forms
2+
3+
from . import models
4+
5+
6+
class ThumbnailModelForm(forms.ModelForm):
7+
8+
class Meta:
9+
model = models.ThumbnailModel
10+
fields = '__all__'

tests/models.py

+8-9
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
from django.core.files.base import ContentFile
44
from django.core.files.storage import FileSystemStorage
55
from django.db import models
6-
from django.db.models.signals import post_delete, pre_save
76
from PIL import Image
87

98
from stdimage import StdImageField
109
from stdimage.models import StdImageFieldFile
11-
from stdimage.utils import (pre_delete_delete_callback, pre_save_delete_callback,
12-
render_variations,)
10+
from stdimage.utils import render_variations
1311
from stdimage.validators import MaxSizeValidator, MinSizeValidator
1412

1513
upload_to = 'img/'
@@ -24,7 +22,11 @@ class AdminDeleteModel(models.Model):
2422
"""can be deleted through admin"""
2523
image = StdImageField(
2624
upload_to=upload_to,
27-
blank=True
25+
variations={
26+
'thumbnail': (100, 75),
27+
},
28+
blank=True,
29+
delete_orphans=True,
2830
)
2931

3032

@@ -52,7 +54,8 @@ class ThumbnailModel(models.Model):
5254
image = StdImageField(
5355
upload_to=upload_to,
5456
blank=True,
55-
variations={'thumbnail': (100, 75)}
57+
variations={'thumbnail': (100, 75)},
58+
delete_orphans=True,
5659
)
5760

5861

@@ -162,7 +165,3 @@ class CustomRenderVariationsModel(models.Model):
162165
variations={'thumbnail': (150, 150)},
163166
render_variations=custom_render_variations,
164167
)
165-
166-
167-
post_delete.connect(pre_delete_delete_callback, sender=SimpleModel)
168-
pre_save.connect(pre_save_delete_callback, sender=AdminDeleteModel)

tests/test_forms.py

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import os
2+
3+
from tests.test_models import TestStdImage
4+
5+
from . import forms, models
6+
7+
8+
class TestStdImageField(TestStdImage):
9+
10+
def test_save_form_data__new(self, db):
11+
instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif'])
12+
org_path = instance.image.path
13+
assert os.path.exists(org_path)
14+
form = forms.ThumbnailModelForm(
15+
files=dict(image=self.fixtures['600x400.jpg']),
16+
instance=instance,
17+
)
18+
assert form.is_valid()
19+
obj = form.save()
20+
assert obj.image.name == 'img/600x400.jpg'
21+
assert os.path.exists(instance.image.path)
22+
assert not os.path.exists(org_path)
23+
24+
def test_save_form_data__false(self, db):
25+
instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif'])
26+
org_path = instance.image.path
27+
assert os.path.exists(org_path)
28+
form = forms.ThumbnailModelForm(
29+
data={'image-clear': '1'},
30+
instance=instance,
31+
)
32+
assert form.is_valid()
33+
obj = form.save()
34+
assert obj.image._file is None
35+
assert not os.path.exists(org_path)
36+
37+
def test_save_form_data__none(self, db):
38+
instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif'])
39+
org_path = instance.image.path
40+
assert os.path.exists(org_path)
41+
form = forms.ThumbnailModelForm(
42+
data={'image': None},
43+
instance=instance,
44+
)
45+
assert form.is_valid()
46+
obj = form.save()
47+
assert obj.image
48+
assert os.path.exists(org_path)

tests/test_models.py

+16-9
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,30 @@ class TestUtils(TestStdImage):
170170
"""Tests Utils"""
171171

172172
def test_deletion_singnal_receiver(self, db):
173-
obj = SimpleModel.objects.create(
173+
obj = AdminDeleteModel.objects.create(
174174
image=self.fixtures['100.gif']
175175
)
176+
path = obj.image.path
176177
obj.delete()
177-
assert not os.path.exists(os.path.join(IMG_DIR, 'image.gif'))
178+
assert not os.path.exists(path)
179+
180+
def test_deletion_singnal_receiver_many(self, db):
181+
obj = AdminDeleteModel.objects.create(
182+
image=self.fixtures['100.gif']
183+
)
184+
path = obj.image.path
185+
AdminDeleteModel.objects.all().delete()
186+
assert not os.path.exists(path)
178187

179188
def test_pre_save_delete_callback_clear(self, admin_client):
180-
AdminDeleteModel.objects.create(
189+
obj = AdminDeleteModel.objects.create(
181190
image=self.fixtures['100.gif']
182191
)
192+
path = obj.image.path
183193
admin_client.post('/admin/tests/admindeletemodel/1/change/', {
184194
'image-clear': 'checked',
185195
})
186-
assert not os.path.exists(os.path.join(IMG_DIR, 'image.gif'))
196+
assert not os.path.exists(path)
187197

188198
def test_pre_save_delete_callback_new(self, admin_client):
189199
AdminDeleteModel.objects.create(
@@ -195,11 +205,8 @@ def test_pre_save_delete_callback_new(self, admin_client):
195205
assert not os.path.exists(os.path.join(IMG_DIR, 'image.gif'))
196206

197207
def test_render_variations_callback(self, db):
198-
UtilVariationsModel.objects.create(image=self.fixtures['100.gif'])
199-
file_path = os.path.join(
200-
IMG_DIR,
201-
'100.thumbnail.gif'
202-
)
208+
obj = UtilVariationsModel.objects.create(image=self.fixtures['100.gif'])
209+
file_path = obj.image.thumbnail.path
203210
assert os.path.exists(file_path)
204211

205212

0 commit comments

Comments
 (0)