Index: django/forms/models.py
===================================================================
--- django/forms/models.py (revision 9382)
+++ django/forms/models.py (working copy)
@@ -7,10 +7,12 @@
from django.utils.datastructures import SortedDict
from django.utils.text import get_text_list, capfirst
from django.utils.translation import ugettext_lazy as _
+from django.core.files.uploadedfile import UploadedFile
+from django.core.files import directories
from util import ValidationError, ErrorList
from forms import BaseForm, get_declared_fields
-from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
+from fields import Field, ChoiceField, IntegerField, FileField, DeletableFileField, EMPTY_VALUES
from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
from widgets import media_property
from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME
@@ -20,6 +22,8 @@
except NameError:
from sets import Set as set # Python 2.3 fallback
+import os
+
__all__ = (
'ModelForm', 'BaseModelForm', 'model_to_dict', 'fields_for_model',
'save_instance', 'form_for_fields', 'ModelChoiceField',
@@ -27,6 +31,36 @@
)
+def delete_file_field_file(file_field, model_instance, delete_from_disk=False, delete_empty_directories=False):
+ """
+ Deletes the file of a model instances FileField. But only if no other
+ instances are pointing to it and it is not the default.
+ """
+ file_obj = getattr(model_instance, file_field.attname)
+ if not file_obj:
+ return
+
+ # check whether we need to delete the file from disk or just clear the field
+ if delete_from_disk:
+ # create a queryset to determine if the file is being referenced
+ # by *another* instance.
+ queryset = model_instance.__class__._default_manager.\
+ filter(**{file_field.attname: file_obj.name}).\
+ exclude(pk=model_instance.pk)
+ # delete the file if is not referenced elsewhere and is not the default
+ if not queryset and file_obj.name != file_field.default:
+ file_path = file_obj.path
+ file_obj.delete(save=False)
+ if delete_empty_directories:
+ from django.conf import settings
+ directories.delete_empty_directories(stop_dir=settings.MEDIA_ROOT, dir=os.path.dirname(file_path))
+
+ # TODO: look into this more. initially it was using None, but that
+ # was not working as the model field is null=False since there
+ # seems to be no reason why when the database field is a varchar.
+ setattr(model_instance, file_field.attname, u"")
+
+
def save_instance(form, instance, fields=None, fail_message='saved',
commit=True, exclude=None):
"""
@@ -52,13 +86,28 @@
continue
# Defer saving file-type fields until after the other fields, so a
# callable upload_to can use the values from other fields.
- if isinstance(f, models.FileField):
+ if isinstance(f, models.FileField) or isinstance(f, DeletableFileField):
file_field_list.append(f)
else:
f.save_form_data(instance, cleaned_data[f.name])
for f in file_field_list:
- f.save_form_data(instance, cleaned_data[f.name])
+ cleaned_field_data = cleaned_data[f.name]
+ # if this fields form field is a DeletableFileField instance we may need to delete the file
+ delete_file = False
+ if isinstance(form.fields[f.name], DeletableFileField):
+ # unpack the actual data and the delete checkbox value
+ cleaned_field_data, delete_file = cleaned_field_data
+ # replace file
+ if isinstance(cleaned_field_data, UploadedFile):
+ # TODO: if a user is uploading a file and checked the box that will
+ # call delete_file twice. 1) to delete the current file and 2) to
+ # delete the just uploaded file. maybe make this more clear or have a
+ # better sensible default.
+ delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
+ f.save_form_data(instance, cleaned_field_data)
+ if delete_file and f.blank:
+ delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
# Wrap up the saving of m2m data as a function.
def save_m2m():
@@ -169,8 +218,10 @@
self.model = getattr(options, 'model', None)
self.fields = getattr(options, 'fields', None)
self.exclude = getattr(options, 'exclude', None)
+ self.files_add_delete_option = getattr(options, 'files_add_delete_option', True)
+ self.files_delete_from_disk = getattr(options, 'files_delete_from_disk', True)
+ self.files_delete_empty_dirs = getattr(options, 'files_delete_empty_dirs', False)
-
class ModelFormMetaclass(type):
def __new__(cls, name, bases, attrs):
formfield_callback = attrs.pop('formfield_callback',
@@ -196,6 +247,12 @@
# Override default model fields with any custom declared ones
# (plus, include all the other declared fields).
fields.update(declared_fields)
+
+ if opts.files_add_delete_option:
+ # wrap form FileFields in DeletableFileFields to show current file and get delete checkboxes (but only if blank=True)
+ for field_name, field in fields.items():
+ if isinstance(field, FileField):
+ fields[field_name] = DeletableFileField(file_field_to_wrap=field, show_delete_checkbox=opts.model._meta.get_field(field_name).blank)
else:
fields = declared_fields
new_class.declared_fields = declared_fields
@@ -322,7 +379,7 @@
__metaclass__ = ModelFormMetaclass
def modelform_factory(model, form=ModelForm, fields=None, exclude=None,
- formfield_callback=lambda f: f.formfield()):
+ formfield_callback=lambda f: f.formfield(), **kwargs):
# HACK: we should be able to construct a ModelForm without creating
# and passing in a temporary inner class
class Meta:
@@ -330,6 +387,10 @@
setattr(Meta, 'model', model)
setattr(Meta, 'fields', fields)
setattr(Meta, 'exclude', exclude)
+ # only add the options to the Meta class that are actually passed in via kwargs as the defaults are already in ModelFormOptions!
+ for attr in ('files_add_delete_option', 'files_delete_from_disk', 'files_delete_empty_dirs'):
+ if attr in kwargs:
+ setattr(Meta, attr, kwargs[attr])
class_name = model.__name__ + 'Form'
return ModelFormMetaclass(class_name, (form,), {'Meta': Meta,
'formfield_callback': formfield_callback})
Index: django/forms/fields.py
===================================================================
--- django/forms/fields.py (revision 9382)
+++ django/forms/fields.py (working copy)
@@ -28,7 +28,7 @@
from django.utils.encoding import smart_unicode, smart_str
from util import ErrorList, ValidationError
-from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, TimeInput, SplitHiddenDateTimeWidget
+from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, TimeInput, SplitHiddenDateTimeWidget, DeletableFileInput, DeleteCheckboxInput
from django.core.files.uploadedfile import SimpleUploadedFile as UploadedFile
__all__ = (
@@ -36,7 +36,7 @@
'DEFAULT_DATE_INPUT_FORMATS', 'DateField',
'DEFAULT_TIME_INPUT_FORMATS', 'TimeField',
'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField', 'TimeField',
- 'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField',
+ 'RegexField', 'EmailField', 'FileField', 'DeletableFileField', 'ImageField', 'URLField',
'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField',
'ComboField', 'MultiValueField', 'FloatField', 'DecimalField',
'SplitDateTimeField', 'IPAddressField', 'FilePathField', 'SlugField',
@@ -525,6 +525,29 @@
f.seek(0)
return f
+class DeletableFileField(Field):
+ """
+ A Field that adds a delete checkbox to a FileField and shows the current value. file_field_to_wrap is the
+ FileField subclass or its instance to be wrapped. If show_delete_checkbox is set to False the field just
+ displays the current value and no delete checkbox. It returns a tupel with the actual FileField value and
+ the state of the delete checkbox.
+ """
+ def __init__(self, file_field_to_wrap=FileField, show_delete_checkbox=True, *args, **kwargs):
+ self.fields = (
+ isinstance(file_field_to_wrap, type) and file_field_to_wrap() or file_field_to_wrap, # instantiate if it is a class
+ BooleanField(widget=DeleteCheckboxInput, required=False),
+ )
+ # get widgets from the fields and pass them to our MultiWidget
+ defaults = {
+ 'widget': DeletableFileInput([f.widget for f in self.fields], show_delete_checkbox=show_delete_checkbox),
+ 'required': self.fields[0].required, # this is just for display purposes, the actual check is performed by the FileField
+ }
+ defaults.update(kwargs)
+ super(DeletableFileField, self).__init__(*args, **defaults)
+
+ def clean(self, value, initial=None):
+ return (self.fields[0].clean(value[0], initial), self.fields[1].clean(value[1]))
+
url_re = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9-]+\.)+[A-Z]{2,6}|' #domain...
Index: django/forms/forms.py
===================================================================
--- django/forms/forms.py (revision 9382)
+++ django/forms/forms.py (working copy)
@@ -9,7 +9,7 @@
from django.utils.encoding import StrAndUnicode, smart_unicode, force_unicode
from django.utils.safestring import mark_safe
-from fields import Field, FileField
+from fields import Field, FileField, DeletableFileField
from widgets import Media, media_property, TextInput, Textarea
from util import flatatt, ErrorDict, ErrorList, ValidationError
@@ -224,7 +224,7 @@
# widgets split data over several HTML fields.
value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
try:
- if isinstance(field, FileField):
+ if isinstance(field, (FileField, DeletableFileField)):
initial = self.initial.get(name, field.initial)
value = field.clean(value, initial)
else:
Index: django/forms/widgets.py
===================================================================
--- django/forms/widgets.py (revision 9382)
+++ django/forms/widgets.py (working copy)
@@ -16,13 +16,15 @@
from django.utils.encoding import StrAndUnicode, force_unicode
from django.utils.safestring import mark_safe
from django.utils import datetime_safe
+from django.utils.translation import ugettext as _
+from django.core.files.uploadedfile import UploadedFile
from datetime import time
from util import flatatt
from urlparse import urljoin
__all__ = (
'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput',
- 'HiddenInput', 'MultipleHiddenInput',
+ 'HiddenInput', 'MultipleHiddenInput', 'DeletableFileInput', 'DeleteCheckboxInput',
'FileInput', 'DateTimeInput', 'TimeInput', 'Textarea', 'CheckboxInput',
'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect',
'CheckboxSelectMultiple', 'MultiWidget',
@@ -650,6 +652,74 @@
return media
media = property(_get_media)
+class DeletableFileInput(MultiWidget):
+ """
+ A MultiWidget for the use with the DeletableFileField. The contained widgets are
+ specified by the fields constructor. If the value of the model FileField is None or
+ UploadedFile the rendered widget of the form FileField is just passed through.
+ """
+ def __init__(self, widgets, attrs=None, show_delete_checkbox=True):
+ self.show_delete_checkbox = show_delete_checkbox
+ super(DeletableFileInput, self).__init__(widgets, attrs)
+
+ def render(self, name, value, attrs=None):
+ self.show_checkbox = False
+ return super(DeletableFileInput, self).render(name, value, attrs)
+
+ def decompress(self, value):
+ # decompress() is only called when we have initial data (not POST data)
+ # so we can be sure we have a proper File object here
+ if value:
+ self.show_checkbox = True
+ self.current_file = value
+ return [value, False]
+ # the value for DeleteCheckboxInput doesn't really matter as we won't show it anyway
+ return [None, None]
+
+ def format_output(self, rendered_widgets):
+ """
+ Adds a link to the current file and wraps everything in spans to afford formatting.
+ """
+ # this is a bit strange as we to do the actual test in decompress()
+ # but we don't have the value of the model FileField here so ...
+ if not self.show_checkbox:
+ return rendered_widgets[0]
+ # current file
+ output = [u'%s %s ' % (
+ u'deletable-file-input',
+ u'deletable-file-input-current',
+ _('Currently:'),
+ self.current_file.url,
+ self.current_file,
+ )]
+ # file input
+ output.append('%s %s ' % (
+ u'deletable-file-input-file-field',
+ _('Change:'),
+ rendered_widgets[0], # the FileFields widget
+ ))
+ # delete checkbox
+ if self.show_delete_checkbox:
+ output.append('%s' % (
+ u'deletable-file-input-delete-checkbox',
+ rendered_widgets[1], # the checkbox
+ ))
+ output.append('')
+ return u''.join(output)
+
+class DeleteCheckboxInput(CheckboxInput):
+ """
+ A CheckboxInput with a delete label next to it. It only renders if value is not None.
+ """
+ def render(self, name, value, attrs=None):
+ if value == None:
+ return u''
+ return u'%s ' % (
+ super(DeleteCheckboxInput, self).render(name, value, attrs),
+ attrs["id"],
+ _("Delete"),
+ )
+
class SplitDateTimeWidget(MultiWidget):
"""
A Widget that splits datetime input into two boxes.
Index: django/core/files/directories.py
===================================================================
--- django/core/files/directories.py (revision 0)
+++ django/core/files/directories.py (revision 0)
@@ -0,0 +1,20 @@
+import os
+
+def delete_empty_directories(stop_dir, dir):
+ """
+ Deletes *empty* directories starting with dir and working it's way up till it hits stop_dir.
+ """
+ # normalize paths
+ stop_dir = os.path.abspath(stop_dir)
+ dir = os.path.abspath(dir)
+ if not os.path.exists(stop_dir):
+ raise Exception("stop_dir ('%s') is not a valid directory." % stop_dir)
+ try:
+ while True:
+ if dir == stop_dir or len(dir) < 4:
+ return
+ os.rmdir(dir)
+ dir = os.path.dirname(dir)
+ except OSError:
+ # stop if we have not enough permissions or dir is not empty
+ pass
Index: django/contrib/admin/media/css/forms.css
===================================================================
--- django/contrib/admin/media/css/forms.css (revision 9382)
+++ django/contrib/admin/media/css/forms.css (working copy)
@@ -146,6 +146,21 @@
display: inline !important;
}
+/* DELETABLE FILE INPUTS */
+
+.deletable-file-input {
+ display: block;
+ padding-left: 9.818181em; /* should match the 9em that inputs get pushed to the side by labels */
+}
+
+.deletable-file-input-delete-checkbox {
+ display: block;
+}
+
+.deletable-file-input-delete-checkbox label {
+ display:inline; float:none;
+}
+
/* MONOSPACE TEXTAREAS */
fieldset.monospace textarea {
Index: django/contrib/admin/options.py
===================================================================
--- django/contrib/admin/options.py (revision 9382)
+++ django/contrib/admin/options.py (working copy)
@@ -105,11 +105,6 @@
kwargs['widget'] = widgets.AdminTextInputWidget
return db_field.formfield(**kwargs)
- # For FileFields and ImageFields add a link to the current file.
- if isinstance(db_field, models.ImageField) or isinstance(db_field, models.FileField):
- kwargs['widget'] = widgets.AdminFileWidget
- return db_field.formfield(**kwargs)
-
# For ForeignKey or ManyToManyFields, use a special widget.
if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
if isinstance(db_field, models.ForeignKey) and db_field.name in self.raw_id_fields:
Index: django/contrib/admin/widgets.py
===================================================================
--- django/contrib/admin/widgets.py (revision 9382)
+++ django/contrib/admin/widgets.py (working copy)
@@ -80,21 +80,6 @@
class AdminRadioSelect(forms.RadioSelect):
renderer = AdminRadioFieldRenderer
-class AdminFileWidget(forms.FileInput):
- """
- A FileField Widget that shows its current value if it has one.
- """
- def __init__(self, attrs={}):
- super(AdminFileWidget, self).__init__(attrs)
-
- def render(self, name, value, attrs=None):
- output = []
- if value and hasattr(value, "url"):
- output.append('%s %s %s ' % \
- (_('Currently:'), value.url, value, _('Change:')))
- output.append(super(AdminFileWidget, self).render(name, value, attrs))
- return mark_safe(u''.join(output))
-
class ForeignKeyRawIdWidget(forms.TextInput):
"""
A Widget for displaying ForeignKeys in the "raw_id" interface rather than
Index: docs/topics/forms/modelforms.txt
===================================================================
--- docs/topics/forms/modelforms.txt (revision 9382)
+++ docs/topics/forms/modelforms.txt (working copy)
@@ -382,6 +382,43 @@
Chances are these notes won't affect you unless you're trying to do something
tricky with subclassing.
+Making ``FileField``s deletable
+===============================
+
+``files_add_delete_option``
+---------------------------
+
+This option is ``True`` by default.
+
+When set to ``True``, all ``FileField``s get delete checkboxes if they have the
+option ``blank=True``. The current file is also displayed above the field, regardless
+of the value of ``blank``.
+
+``files_delete_empty_dirs``
+---------------------------
+
+This option is ``False`` by default.
+
+When set to ``True``, Django removes *empty* directories after each file is deleted. It
+starts with the directory of the file and then moves it's way up till it reaches the
+``MEDIA_ROOT`` folder. **Warning**: This may delete more directories than you want.
+Use with caution!
+
+For example, if Django deletes the following file:
+
+ /media-root/foo/bar/file.png
+
+It will first try to delete ``/media-root/foo/bar/`` and then ``/media-root/foo/``.
+
+``files_delete_from_disk``
+---------------------------
+
+This option is ``True`` by default.
+
+When set to ``True``, files of ``FileField`` are deleted from the disk. This happens
+when a FileField is updated or when it is cleared. The latter is only possible if
+``files_add_delete_option`` is ``True``.
+
.. _model-formsets:
Model Formsets