Ticket #11663: 11663.3.diff

File 11663.3.diff, 23.1 KB (added by Chris Beaven, 9 years ago)

With some simple docs (and updated to r13350)

  • django/db/models/fields/files.py

     
    2323        self.field = field
    2424        self.storage = field.storage
    2525        self._committed = True
     26        self._replaced = []
     27        self.delete_replaced_files = self.field.delete_replaced
    2628
    2729    def __eq__(self, other):
    2830        # Older code may be expecting FileField values to be simple strings.
     
    8890    # associated model instance.
    8991
    9092    def save(self, name, content, save=True):
     93        if self.delete_replaced_files:
     94            self.delete_replaced()
     95            if self._committed:
     96                # Delete this file as well (since we're saving a new one).
     97                self.delete_if_orphan(save=False)
     98
    9199        name = self.field.generate_filename(self.instance, name)
    92100        self.name = self.storage.save(name, content)
    93         setattr(self.instance, self.field.name, self.name)
     101        setattr(self.instance, self.field.name, self)
    94102
    95103        # Update the filesize cache
    96104        self._size = len(content)
     
    101109            self.instance.save()
    102110    save.alters_data = True
    103111
    104     def delete(self, save=True):
     112    def delete(self, save=True, delete_replaced_files=None):
     113        """
     114        Deletes the file from the backend.
     115       
     116        If ``save`` is ``True`` (default), the file's instance will be saved
     117        after the file is deleted.
     118       
     119        ``delete_replaced_files`` determines whether to also delete replaced
     120        files which are orphans. If set to ``None``, the field's default
     121        setting applies.
     122        """
     123        if delete_replaced_files is None:
     124            delete_replaced_files = self.delete_replaced_files
     125        if delete_replaced_files:
     126            self.delete_replaced()
     127
    105128        # Only close the file if it's already open, which we know by the
    106129        # presence of self._file
    107130        if hasattr(self, '_file'):
     
    111134        self.storage.delete(self.name)
    112135
    113136        self.name = None
    114         setattr(self.instance, self.field.name, self.name)
     137        setattr(self.instance, self.field.name, self)
    115138
    116139        # Delete the filesize cache
    117140        if hasattr(self, '_size'):
     
    122145            self.instance.save()
    123146    delete.alters_data = True
    124147
     148    def delete_if_orphan(self, save=True, queryset=None,
     149                         delete_replaced_files=None):
     150        """
     151        Deletes the file from the backend if no objects in the queryset
     152        reference the file and it's not the default value for future objects.
     153
     154        Otherwise, the file is simply closed so it doesn't tie up resources.
     155       
     156        If ``save`` is ``True`` (default), the file's instance will be saved
     157        if the file is deleted.
     158
     159        Under most circumstances, ``queryset`` does not need to be passed -
     160        it will be calculated based on the current instance.
     161       
     162        ``delete_replaced_files`` determines whether to also delete replaced
     163        files which are orphans. If set to ``None``, the field's default
     164        setting applies.
     165        """
     166        if delete_replaced_files is None:
     167            delete_replaced_files = self.delete_replaced_files
     168        if delete_replaced_files:
     169            self.delete_replaced()
     170
     171        if queryset is None:
     172            queryset = self.instance._default_manager.all()
     173            if self.instance.pk:
     174                queryset = queryset.exclude(pk=self.instance.pk)
     175        queryset = queryset.filter(**{self.field.name: self.name})
     176
     177        if self.name != self.field.default and not queryset:
     178            self.delete(save=save, delete_replaced_files=False)
     179        else:
     180            self.close()
     181    delete_if_orphan.alters_data = True
     182
     183    def delete_replaced(self, only_orphans=True, _seen=None):
     184        seen = _seen or []
     185        seen.append(self.name)
     186        for file in self._replaced:
     187            if file._committed and file.name not in seen:
     188                file.delete_replaced(only_orphans=only_orphans, _seen=seen)
     189                if only_orphans:
     190                    file.delete_if_orphan(save=False,
     191                                          delete_replaced_files=False)
     192                else:
     193                    file.delete(save=False, delete_replaced_files=False)
     194        self._replaced = []
     195    delete_replaced.alters_data = True
     196
    125197    def _get_closed(self):
    126198        file = getattr(self, '_file', None)
    127199        return file is None or file.closed
     
    137209        # it's attached to in order to work properly, but the only necessary
    138210        # data to be pickled is the file's name itself. Everything else will
    139211        # be restored later, by FileDescriptor below.
    140         return {'name': self.name, 'closed': False, '_committed': True, '_file': None}
     212        return {'name': self.name, 'closed': False, '_committed': True,
     213                '_file': None,
     214                'delete_replaced_files': self.delete_replaced_files}
    141215
    142216class FileDescriptor(object):
    143217    """
     
    195269            file_copy._committed = False
    196270            instance.__dict__[self.field.name] = file_copy
    197271
    198         # Finally, because of the (some would say boneheaded) way pickle works,
    199         # the underlying FieldFile might not actually itself have an associated
    200         # file. So we need to reset the details of the FieldFile in those cases.
     272        # Because of the (some would say boneheaded) way pickle works, the
     273        # underlying FieldFile might not actually itself have an associated
     274        # file. So we need to reset the details of the FieldFile in those
     275        # cases.
    201276        elif isinstance(file, FieldFile) and not hasattr(file, 'field'):
    202277            file.instance = instance
    203278            file.field = self.field
    204279            file.storage = self.field.storage
    205280
     281        # Finally, the file set may have been a FieldFile from another
     282        # instance, so copy it if the instance doesn't match.
     283        elif isinstance(file, FieldFile) and file.instance != instance:
     284            file_copy = self.field.attr_class(instance, self.field, file.name)
     285            file_copy.file = file
     286            file_copy._committed = file._committed
     287            instance.__dict__[self.field.name] = file_copy
     288
    206289        # That was fun, wasn't it?
    207290        return instance.__dict__[self.field.name]
    208291
    209292    def __set__(self, instance, value):
     293        if self.field.name in instance.__dict__:
     294            previous_file = getattr(instance, self.field.name)
     295        else:
     296            previous_file = None
    210297        instance.__dict__[self.field.name] = value
     298        if previous_file:
     299            # Rather than just using value, we get the file from the instance,
     300            # so that the __get__ logic of the file descriptor is processed.
     301            # This ensures we will be dealing with a FileField (or subclass of
     302            # FileField) instance.
     303            file = getattr(instance, self.field.name)
     304            if previous_file is not file:
     305                # Remember that the previous file was replaced.
     306                file._replaced.append(previous_file)
    211307
    212308class FileField(Field):
    213309    # The class to wrap instance attributes in. Accessing the file object off
     
    219315
    220316    description = ugettext_lazy("File path")
    221317
    222     def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, **kwargs):
     318    def __init__(self, verbose_name=None, name=None, upload_to='',
     319                 storage=None, delete_replaced=False, **kwargs):
    223320        for arg in ('primary_key', 'unique'):
    224321            if arg in kwargs:
    225322                raise TypeError("'%s' is not a valid argument for %s." % (arg, self.__class__))
     
    228325        self.upload_to = upload_to
    229326        if callable(upload_to):
    230327            self.generate_filename = upload_to
     328        self.delete_replaced = delete_replaced
    231329
    232330        kwargs['max_length'] = kwargs.get('max_length', 100)
    233331        super(FileField, self).__init__(verbose_name, name, **kwargs)
     
    261359        signals.post_delete.connect(self.delete_file, sender=cls)
    262360
    263361    def delete_file(self, instance, sender, **kwargs):
     362        """
     363        Signal receiver which deletes an attached file from the backend when
     364        the model is deleted.
     365        """
    264366        file = getattr(instance, self.attname)
    265         # If no other object of this type references the file,
    266         # and it's not the default value for future objects,
    267         # delete it from the backend.
    268         if file and file.name != self.default and \
    269             not sender._default_manager.filter(**{self.name: file.name}):
    270                 file.delete(save=False)
    271         elif file:
    272             # Otherwise, just close the file, so it doesn't tie up resources.
    273             file.close()
     367        if file:
     368            file.delete_if_orphan(save=False,
     369                                  queryset=sender._default_manager.all())
    274370
    275371    def get_directory_name(self):
    276372        return os.path.normpath(force_unicode(datetime.datetime.now().strftime(smart_str(self.upload_to))))
  • tests/regressiontests/file_uploads/models.py

     
    44from django.core.files.storage import FileSystemStorage
    55
    66temp_storage = FileSystemStorage(tempfile.mkdtemp())
    7 UPLOAD_TO = os.path.join(temp_storage.location, 'test_upload')
     7UPLOAD_TO_NAME = 'test_upload'
     8UPLOAD_TO = os.path.join(temp_storage.location, UPLOAD_TO_NAME)
    89
    910class FileModel(models.Model):
    10     testfile = models.FileField(storage=temp_storage, upload_to='test_upload')
     11    testfile = models.FileField(storage=temp_storage, upload_to=UPLOAD_TO_NAME)
     12
     13class FileModelDeleteReplaced(models.Model):
     14    testfile = models.FileField(storage=temp_storage, upload_to=UPLOAD_TO_NAME,
     15                                delete_replaced=True)
  • tests/regressiontests/file_uploads/tests.py

     
    1212from django.utils.hashcompat import sha_constructor
    1313from django.http.multipartparser import MultiPartParser
    1414
    15 from models import FileModel, temp_storage, UPLOAD_TO
     15from models import FileModel, FileModelDeleteReplaced, temp_storage, UPLOAD_TO
    1616import uploadhandler
    1717
    1818UNICODE_FILENAME = u'test-0123456789_中文_Orléans.jpg'
     
    302302            'CONTENT_TYPE':     'multipart/form-data; boundary=_foo',
    303303            'CONTENT_LENGTH':   '1'
    304304        }, StringIO('x'), [], 'utf-8')
     305
     306class DeleteReplacedTests(TestCase):
     307    def setUp(self):
     308        if not os.path.isdir(temp_storage.location):
     309            os.makedirs(temp_storage.location)
     310        if os.path.isdir(UPLOAD_TO):
     311            os.chmod(UPLOAD_TO, 0700)
     312            shutil.rmtree(UPLOAD_TO)
     313        self.file_a = SimpleUploadedFile('alpha.txt', 'A')
     314        self.file_b = SimpleUploadedFile('beta.txt', 'B')
     315        self.file_g = SimpleUploadedFile('gamma.txt', 'G')
     316
     317    def tearDown(self):
     318        os.chmod(temp_storage.location, 0700)
     319        shutil.rmtree(temp_storage.location)
     320
     321    def test_instance_track_replaced(self):
     322        """
     323        Setting a new file into a instance's ``FileField`` attribute keeps
     324        track of the old file in the new file's ``_replaced`` list.
     325        """
     326        obj = FileModel()
     327        obj.testfile = self.file_a
     328        fieldfile_a = obj.testfile
     329        self.assertEqual(obj.testfile._replaced, [])
     330        # After a save, nothing changes.
     331        obj.save()
     332        self.assertEqual(obj.testfile._replaced, [])
     333        # Set to B, B replaces A
     334        obj.testfile = self.file_b
     335        fieldfile_b = obj.testfile
     336        self.assertEqual(obj.testfile._replaced, [fieldfile_a])
     337        # Set to G, G replaces B (which in turn replaces A)
     338        obj.testfile = self.file_g
     339        fieldfile_g = obj.testfile
     340        self.assertEqual(obj.testfile._replaced, [fieldfile_b])
     341        self.assertEqual(obj.testfile._replaced[0]._replaced, [fieldfile_a])
     342
     343    def test_default_on_delete(self):
     344        """
     345        Deleting a FieldFile with a standard FileField doesn't delete replaced
     346        files by default. To delete replaced files explicitly, you can set
     347        ``delete_replaced_files=True``.
     348        """
     349        obj = FileModel.objects.create(testfile=self.file_a)
     350        obj.testfile = self.file_b
     351        obj.testfile.delete()
     352        self.assertEqual(os.listdir(UPLOAD_TO), ['alpha.txt'])
     353        # Explicitly delete replaced files.
     354        obj.testfile.delete(delete_replaced_files=True)
     355        self.assertEqual(os.listdir(UPLOAD_TO), [])
     356
     357    def test_delete_replaced_on_delete(self):
     358        """
     359        Deleting a FieldFile which its FileField set ``delete_replaced=True``
     360        deletes replaced files which are orphans.
     361        """
     362        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     363        obj.testfile = self.file_b
     364        obj.testfile.delete()
     365        self.assertEqual(os.listdir(UPLOAD_TO), [])
     366        # If another instance has a reference to the file it won't be deleted.
     367        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     368        obj2 = FileModelDeleteReplaced.objects.create(testfile=obj.testfile)
     369        obj.testfile = self.file_b
     370        obj.testfile.delete()
     371        self.assertEqual(os.listdir(UPLOAD_TO), ['alpha.txt'])
     372
     373    def test_default_on_delete_if_orphan(self):
     374        """
     375        Calling ``delete_if_orphan`` on a FieldFile with a standard FileField
     376        doesn't delete replaced files by default. To delete replaced files
     377        explicitly, you can set ``delete_replaced_files=True``.
     378        """
     379        obj = FileModel.objects.create(testfile=self.file_a)
     380        obj.testfile = self.file_b
     381        obj.testfile.delete_if_orphan()
     382        expected = ['alpha.txt']
     383        self.assertEqual(os.listdir(UPLOAD_TO), expected)
     384        # If another instance has a reference to the file it won't be deleted.
     385        obj = FileModel.objects.create(testfile=self.file_b)
     386        obj.testfile = self.file_g
     387        obj2 = FileModel.objects.create(testfile=obj.testfile)
     388        obj.testfile.delete_if_orphan()
     389        files = os.listdir(UPLOAD_TO)
     390        files.sort()
     391        expected.extend(['beta.txt', 'gamma.txt'])
     392        self.assertEqual(files, expected)
     393        # Explicitly delete replaced files.
     394        obj.testfile.delete(delete_replaced_files=True)
     395        expected.remove('beta.txt')
     396        self.assertEqual(os.listdir(UPLOAD_TO), expected)
     397
     398    def test_delete_replaced_on_delete_if_orphan(self):
     399        """
     400        Deleting a FieldFile which its FileField set ``delete_replaced=True``
     401        deletes replaced files which are orphans.
     402        """
     403        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     404        obj.testfile = self.file_b
     405        obj.testfile.delete()
     406        self.assertEqual(os.listdir(UPLOAD_TO), [])
     407        # If another instance has a reference to the file it won't be deleted.
     408        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_b)
     409        obj.testfile = self.file_g
     410        obj2 = FileModelDeleteReplaced.objects.create(testfile=obj.testfile)
     411        obj.testfile.delete_if_orphan()
     412        self.assertEqual(os.listdir(UPLOAD_TO), ['gamma.txt'])
     413
     414    def test_default_on_save(self):
     415        """
     416        Saving a FieldFile with a standard FileField doesn't delete replaced
     417        files.
     418        """
     419        obj = FileModel.objects.create(testfile=self.file_a)
     420        obj.testfile.save(name=self.file_b.name, content=self.file_b,
     421                          save=False)
     422        files = os.listdir(UPLOAD_TO)
     423        files.sort()
     424        self.assertEqual(files, ['alpha.txt', 'beta.txt'])
     425
     426    def test_delete_replaced_on_save(self):
     427        """
     428        Saving a FieldFile which its FileField set ``delete_replaced=True``
     429        deletes replaced files which are orphans.
     430        """
     431        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     432        obj.testfile.save(name=self.file_b.name, content=self.file_b,
     433                          save=False)
     434        expected = ['beta.txt']
     435        self.assertEqual(os.listdir(UPLOAD_TO), expected)
     436        # If another instance has a reference to the file it won't be deleted.
     437        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     438        obj2 = FileModelDeleteReplaced.objects.create(testfile=obj.testfile)
     439        obj.testfile.save(name=self.file_g.name, content=self.file_g,
     440                          save=False)
     441        files = os.listdir(UPLOAD_TO)
     442        files.sort()
     443        expected.extend(['alpha.txt', 'gamma.txt'])
     444        expected.sort()
     445        self.assertEqual(files, expected)
     446
     447    def test_delete_replaced(self):
     448        """
     449        Explicitly calling the ``delete_replaced`` method of a ``FieldFile``
     450        recursively deletes replaced files which are orphans.
     451       
     452        This happens regardless of the `FileField``'s ``delete_replaced``
     453        attribute.
     454       
     455        To delete all replaced files without considering if they are orphans,
     456        set ``only_orphans=False``.
     457        """
     458        obj = FileModel.objects.create(testfile=self.file_a)
     459        obj.testfile = self.file_b
     460        obj.save()
     461        obj2 = FileModel.objects.create(testfile=obj.testfile)
     462        obj.testfile = self.file_g
     463        obj.save()
     464        files = os.listdir(UPLOAD_TO)
     465        files.sort()
     466        self.assertEqual(files, ['alpha.txt', 'beta.txt', 'gamma.txt'])
     467        # Now delete_replaced.
     468        obj.testfile.delete_replaced()
     469        files = os.listdir(UPLOAD_TO)
     470        files.sort()
     471        self.assertEqual(files, ['beta.txt', 'gamma.txt'])
     472
     473    def test_delete_replaced_all(self):
     474        """
     475        Calling the ``delete_replaced`` method of a ``FieldFile`` with
     476        ``only_orphans=False`` recursively deletes replaced files without
     477        considering if they are orphans.
     478        """
     479        obj = FileModel.objects.create(testfile=self.file_a)
     480        obj.testfile = self.file_b
     481        obj.save()
     482        obj2 = FileModel.objects.create(testfile=obj.testfile)
     483        obj.testfile = self.file_g
     484        obj.save()
     485        files = os.listdir(UPLOAD_TO)
     486        files.sort()
     487        self.assertEqual(files, ['alpha.txt', 'beta.txt', 'gamma.txt'])
     488        # Now delete_replaced.
     489        obj.testfile.delete_replaced(only_orphans=False)
     490        self.assertEqual(os.listdir(UPLOAD_TO), ['gamma.txt'])
     491
     492    def test_replace_avoids_loop(self):
     493        """
     494        Avoid an infinite loop when A replaces B which replaced A
     495        """
     496        obj = FileModel.objects.create(testfile=self.file_a)
     497        fieldfile_a = obj.testfile
     498        obj.testfile = self.file_b
     499        obj.save()
     500        obj.testfile = fieldfile_a
     501        obj.testfile.delete_replaced()
     502        self.assertEqual(os.listdir(UPLOAD_TO), ['alpha.txt'])
     503
     504    def test_instance_delete(self):
     505        """
     506        Deleting an instance deletes replaced files. For backwards
     507        compatibility, this is regardless of the `FileField``'s
     508        ``delete_replaced`` attribute. Files are only deleted if no other
     509        instance of the same model type references that file.
     510        """
     511        obj = FileModel.objects.create(testfile=self.file_a)
     512        obj.delete()
     513        self.assertEqual(os.listdir(UPLOAD_TO), [])
     514
     515        obj = FileModel.objects.create(testfile=self.file_a)
     516        obj2 = FileModel.objects.create(testfile=obj.testfile)
     517        self.assertEqual(os.listdir(UPLOAD_TO), ['alpha.txt'])
     518
     519    def test_default_instance_replace_file(self):
     520        """
     521        Saving a model with a standard FileField doesn't delete replaced files
     522        when the instance is saved.
     523        """
     524        obj = FileModel.objects.create(testfile=self.file_a)
     525        obj.testfile = self.file_b
     526        obj.save()
     527        files = os.listdir(UPLOAD_TO)
     528        files.sort()
     529        self.assertEqual(files, ['alpha.txt', 'beta.txt'])
     530
     531    def test_delete_replaced_instance_replace_file(self):
     532        """
     533        If the model's FileField sets ``delete_replaced=True``, replacing an
     534        instance's file with another file will cause the old file to be deleted
     535        when the instance is saved.
     536       
     537        Files are only deleted if no other instance of the same model type
     538        references that file.
     539        """
     540        obj = FileModelDeleteReplaced.objects.create(testfile=self.file_a)
     541        obj.testfile = self.file_b
     542        obj.save()
     543        self.assertEqual(os.listdir(UPLOAD_TO), ['beta.txt'])
     544
     545        obj2 = FileModelDeleteReplaced.objects.create(testfile=obj.testfile)
     546        obj.testfile = self.file_g
     547        obj.save()
     548        files = os.listdir(UPLOAD_TO)
     549        files.sort()
     550        self.assertEqual(files, ['beta.txt', 'gamma.txt'])
  • docs/ref/models/fields.txt

     
    465465``FileField``
    466466-------------
    467467
    468 .. class:: FileField(upload_to=None, [max_length=100, **options])
     468.. class:: FileField(upload_to=None, [max_length=100, storage=None, delete_replaced=False, **options])
    469469
    470470A file-upload field.
    471471
     
    511511                                when determining the final destination path.
    512512        ======================  ===============================================
    513513
    514 Also has one optional argument:
     514Also has two optional arguments:
    515515
    516516.. attribute:: FileField.storage
    517517
     
    520520    Optional. A storage object, which handles the storage and retrieval of your
    521521    files. See :ref:`topics-files` for details on how to provide this object.
    522522
     523.. attribute:: FileField.delete_replaced
     524
     525    .. versionadded:: 1.3
     526
     527    Delete replaced files (if they are `orphaned`__) when the model instance is
     528    saved.
     529
     530    .. __: `deletion of orphaned files`_
     531
    523532The admin represents this field as an ``<input type="file">`` (a file-upload
    524533widget).
    525534
     
    611620The optional ``save`` argument controls whether or not the instance is saved
    612621after the file has been deleted. Defaults to ``True``.
    613622
     623Deletion of Orphaned Files
     624~~~~~~~~~~~~~~~~~~~~~~~~~~
     625
     626When a model is deleted, the underlying file is deleted if it is considered an
     627orphan.
     628
     629A file's orphan status is decided by checking the field all other instances for
     630this model for a reference to the same file.
     631
     632To remove this default behavior, remove the signal::
     633
     634    from django.db.signals import post_delete
     635    post_delete.disconnect(SomeModel._meta.get_field('the_file_field').delete_file)
     636
     637.. versionadded:: 1.3
     638   Deleting replaced files is now possible.
     639
     640When a file field is changed on a model instance, the replaced file it is *not*
     641deleted by default.
     642
     643To automatically delete replaced files that are orphaned when the model instance
     644is saved, set ``delete_replaced=True`` on the ``FileField``.
     645
    614646``FilePathField``
    615647-----------------
    616648
Back to Top