Ticket #7048: DeletableFileFields-02.diff

File DeletableFileFields-02.diff, 19.6 KB (added by , 11 years ago)

Moved documentation to the correct file

  • django/forms/models.py

     
    77from django.utils.datastructures import SortedDict
    88from django.utils.text import get_text_list, capfirst
    99from django.utils.translation import ugettext_lazy as _
     10from django.core.files.uploadedfile import UploadedFile
     11from django.core.files import directories
    1012
    1113from util import ValidationError, ErrorList
    1214from forms import BaseForm, get_declared_fields
    13 from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
     15from fields import Field, ChoiceField, IntegerField, FileField, DeletableFileField, EMPTY_VALUES
    1416from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
    1517from widgets import media_property
    1618from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME
     
    2022except NameError:
    2123    from sets import Set as set     # Python 2.3 fallback
    2224
     25import os
     26
    2327__all__ = (
    2428    'ModelForm', 'BaseModelForm', 'model_to_dict', 'fields_for_model',
    2529    'save_instance', 'form_for_fields', 'ModelChoiceField',
     
    2731)
    2832
    2933
     34def delete_file_field_file(file_field, model_instance, delete_from_disk=False, delete_empty_directories=False):
     35    """
     36    Deletes the file of a model instances FileField. But only if no other
     37    instances are pointing to it and it is not the default.
     38    """
     39    file_obj = getattr(model_instance, file_field.attname)
     40    if not file_obj:
     41        return
     42   
     43    # check whether we need to delete the file from disk or just clear the field
     44    if delete_from_disk:
     45        # create a queryset to determine if the file is being referenced
     46        # by *another* instance.
     47        queryset = model_instance.__class__._default_manager.\
     48            filter(**{file_field.attname: file_obj.name}).\
     49            exclude(pk=model_instance.pk)
     50        # delete the file if is not referenced elsewhere and is not the default
     51        if not queryset and file_obj.name != file_field.default:
     52            file_path = file_obj.path
     53            file_obj.delete(save=False)
     54            if delete_empty_directories:
     55                from django.conf import settings
     56                directories.delete_empty_directories(stop_dir=settings.MEDIA_ROOT, dir=os.path.dirname(file_path))
     57           
     58    # TODO: look into this more. initially it was using None, but that
     59    # was not working as the model field is null=False since there
     60    # seems to be no reason why when the database field is a varchar.
     61    setattr(model_instance, file_field.attname, u"")
     62
     63
    3064def save_instance(form, instance, fields=None, fail_message='saved',
    3165                  commit=True, exclude=None):
    3266    """
     
    5286            continue
    5387        # Defer saving file-type fields until after the other fields, so a
    5488        # callable upload_to can use the values from other fields.
    55         if isinstance(f, models.FileField):
     89        if isinstance(f, models.FileField) or isinstance(f, DeletableFileField):
    5690            file_field_list.append(f)
    5791        else:
    5892            f.save_form_data(instance, cleaned_data[f.name])
    5993           
    6094    for f in file_field_list:
    61         f.save_form_data(instance, cleaned_data[f.name])
     95        cleaned_field_data = cleaned_data[f.name]
     96        # if this fields form field is a DeletableFileField instance we may need to delete the file
     97        delete_file = False
     98        if isinstance(form.fields[f.name], DeletableFileField):
     99            # unpack the actual data and the delete checkbox value
     100            cleaned_field_data, delete_file = cleaned_field_data
     101        # replace file
     102        if isinstance(cleaned_field_data, UploadedFile):
     103            # TODO: if a user is uploading a file and checked the box that will
     104            # call delete_file twice. 1) to delete the current file and 2) to
     105            # delete the just uploaded file. maybe make this more clear or have a
     106            # better sensible default.
     107            delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
     108        f.save_form_data(instance, cleaned_field_data)
     109        if delete_file and f.blank:
     110            delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
    62111       
    63112    # Wrap up the saving of m2m data as a function.
    64113    def save_m2m():
     
    169218        self.model = getattr(options, 'model', None)
    170219        self.fields = getattr(options, 'fields', None)
    171220        self.exclude = getattr(options, 'exclude', None)
     221        self.files_add_delete_option = getattr(options, 'files_add_delete_option', True)
     222        self.files_delete_from_disk = getattr(options, 'files_delete_from_disk', True)
     223        self.files_delete_empty_dirs = getattr(options, 'files_delete_empty_dirs', False)
    172224
    173 
    174225class ModelFormMetaclass(type):
    175226    def __new__(cls, name, bases, attrs):
    176227        formfield_callback = attrs.pop('formfield_callback',
     
    196247            # Override default model fields with any custom declared ones
    197248            # (plus, include all the other declared fields).
    198249            fields.update(declared_fields)
     250           
     251            if opts.files_add_delete_option:
     252                # wrap form FileFields in DeletableFileFields to show current file and get delete checkboxes (but only if blank=True)
     253                for field_name, field in fields.items():
     254                    if isinstance(field, FileField):
     255                        fields[field_name] = DeletableFileField(file_field_to_wrap=field, show_delete_checkbox=opts.model._meta.get_field(field_name).blank)
    199256        else:
    200257            fields = declared_fields
    201258        new_class.declared_fields = declared_fields
     
    322379    __metaclass__ = ModelFormMetaclass
    323380
    324381def modelform_factory(model, form=ModelForm, fields=None, exclude=None,
    325                        formfield_callback=lambda f: f.formfield()):
     382                       formfield_callback=lambda f: f.formfield(), **kwargs):
    326383    # HACK: we should be able to construct a ModelForm without creating
    327384    # and passing in a temporary inner class
    328385    class Meta:
     
    330387    setattr(Meta, 'model', model)
    331388    setattr(Meta, 'fields', fields)
    332389    setattr(Meta, 'exclude', exclude)
     390    # only add the options to the Meta class that are actually passed in via kwargs as the defaults are already in ModelFormOptions!
     391    for attr in ('files_add_delete_option', 'files_delete_from_disk', 'files_delete_empty_dirs'):
     392        if attr in kwargs:
     393            setattr(Meta, attr, kwargs[attr])
    333394    class_name = model.__name__ + 'Form'
    334395    return ModelFormMetaclass(class_name, (form,), {'Meta': Meta,
    335396                              'formfield_callback': formfield_callback})
  • django/forms/fields.py

     
    2828from django.utils.encoding import smart_unicode, smart_str
    2929
    3030from util import ErrorList, ValidationError
    31 from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, TimeInput, SplitHiddenDateTimeWidget
     31from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, TimeInput, SplitHiddenDateTimeWidget, DeletableFileInput, DeleteCheckboxInput
    3232from django.core.files.uploadedfile import SimpleUploadedFile as UploadedFile
    3333
    3434__all__ = (
     
    3636    'DEFAULT_DATE_INPUT_FORMATS', 'DateField',
    3737    'DEFAULT_TIME_INPUT_FORMATS', 'TimeField',
    3838    'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField', 'TimeField',
    39     'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField',
     39    'RegexField', 'EmailField', 'FileField', 'DeletableFileField', 'ImageField', 'URLField',
    4040    'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField',
    4141    'ComboField', 'MultiValueField', 'FloatField', 'DecimalField',
    4242    'SplitDateTimeField', 'IPAddressField', 'FilePathField', 'SlugField',
     
    525525            f.seek(0)
    526526        return f
    527527
     528class DeletableFileField(Field):
     529    """
     530    A Field that adds a delete checkbox to a FileField and shows the current value. file_field_to_wrap is the
     531    FileField subclass or its instance to be wrapped. If show_delete_checkbox is set to False the field just
     532    displays the current value and no delete checkbox. It returns a tupel with the actual FileField value and
     533    the state of the delete checkbox.
     534    """
     535    def __init__(self, file_field_to_wrap=FileField, show_delete_checkbox=True, *args, **kwargs):
     536        self.fields = (
     537            isinstance(file_field_to_wrap, type) and file_field_to_wrap() or file_field_to_wrap, # instantiate if it is a class
     538            BooleanField(widget=DeleteCheckboxInput, required=False),
     539        )
     540        # get widgets from the fields and pass them to our MultiWidget
     541        defaults = {
     542            'widget': DeletableFileInput([f.widget for f in self.fields], show_delete_checkbox=show_delete_checkbox),
     543            'required': self.fields[0].required, # this is just for display purposes, the actual check is performed by the FileField
     544        }
     545        defaults.update(kwargs)
     546        super(DeletableFileField, self).__init__(*args, **defaults)
     547   
     548    def clean(self, value, initial=None):
     549        return (self.fields[0].clean(value[0], initial), self.fields[1].clean(value[1]))
     550
    528551url_re = re.compile(
    529552    r'^https?://' # http:// or https://
    530553    r'(?:(?:[A-Z0-9-]+\.)+[A-Z]{2,6}|' #domain...
  • django/forms/forms.py

     
    99from django.utils.encoding import StrAndUnicode, smart_unicode, force_unicode
    1010from django.utils.safestring import mark_safe
    1111
    12 from fields import Field, FileField
     12from fields import Field, FileField, DeletableFileField
    1313from widgets import Media, media_property, TextInput, Textarea
    1414from util import flatatt, ErrorDict, ErrorList, ValidationError
    1515
     
    224224            # widgets split data over several HTML fields.
    225225            value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
    226226            try:
    227                 if isinstance(field, FileField):
     227                if isinstance(field, (FileField, DeletableFileField)):
    228228                    initial = self.initial.get(name, field.initial)
    229229                    value = field.clean(value, initial)
    230230                else:
  • django/forms/widgets.py

     
    1616from django.utils.encoding import StrAndUnicode, force_unicode
    1717from django.utils.safestring import mark_safe
    1818from django.utils import datetime_safe
     19from django.utils.translation import ugettext as _
     20from django.core.files.uploadedfile import UploadedFile
    1921from datetime import time
    2022from util import flatatt
    2123from urlparse import urljoin
    2224
    2325__all__ = (
    2426    'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput',
    25     'HiddenInput', 'MultipleHiddenInput',
     27    'HiddenInput', 'MultipleHiddenInput', 'DeletableFileInput', 'DeleteCheckboxInput',
    2628    'FileInput', 'DateTimeInput', 'TimeInput', 'Textarea', 'CheckboxInput',
    2729    'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect',
    2830    'CheckboxSelectMultiple', 'MultiWidget',
     
    650652        return media
    651653    media = property(_get_media)
    652654
     655class DeletableFileInput(MultiWidget):
     656    """
     657    A MultiWidget for the use with the DeletableFileField. The contained widgets are
     658    specified by the fields constructor. If the value of the model FileField is None or
     659    UploadedFile the rendered widget of the form FileField is just passed through.
     660    """
     661    def __init__(self, widgets, attrs=None, show_delete_checkbox=True):
     662        self.show_delete_checkbox = show_delete_checkbox
     663        super(DeletableFileInput, self).__init__(widgets, attrs)
     664   
     665    def render(self, name, value, attrs=None):
     666        self.show_checkbox = False
     667        return super(DeletableFileInput, self).render(name, value, attrs)
     668       
     669    def decompress(self, value):
     670        # decompress() is only called when we have initial data (not POST data)
     671        # so we can be sure we have a proper File object here
     672        if value:
     673            self.show_checkbox = True
     674            self.current_file = value
     675            return [value, False]
     676        # the value for DeleteCheckboxInput doesn't really matter as we won't show it anyway
     677        return [None, None]
     678   
     679    def format_output(self, rendered_widgets):
     680        """
     681        Adds a link to the current file and wraps everything in spans to afford formatting.
     682        """
     683        # this is a bit strange as we to do the actual test in decompress()
     684        # but we don't have the value of the model FileField here so ...
     685        if not self.show_checkbox:
     686            return rendered_widgets[0]
     687        # current file
     688        output = [u'<span class="%s"><span class="%s">%s <a target="_blank" href="%s">%s</a></span><br/>' % (
     689            u'deletable-file-input',
     690            u'deletable-file-input-current',
     691            _('Currently:'),
     692            self.current_file.url,
     693            self.current_file,
     694        )]
     695        # file input
     696        output.append('<span class="%s">%s %s</span><br/>' % (
     697            u'deletable-file-input-file-field',
     698            _('Change:'),
     699            rendered_widgets[0], # the FileFields widget
     700        ))
     701        # delete checkbox
     702        if self.show_delete_checkbox:
     703            output.append('<span class="%s">%s</span>' % (
     704                u'deletable-file-input-delete-checkbox',
     705                rendered_widgets[1], # the checkbox
     706            ))
     707        output.append('</span>')
     708        return u''.join(output)
     709
     710class DeleteCheckboxInput(CheckboxInput):
     711    """
     712    A CheckboxInput with a delete label next to it. It only renders if value is not None.
     713    """
     714    def render(self, name, value, attrs=None):
     715        if value == None:
     716            return u''
     717        return u'%s <label for="%s">%s</label>' % (
     718            super(DeleteCheckboxInput, self).render(name, value, attrs),
     719            attrs["id"],
     720            _("Delete"),
     721        )
     722
    653723class SplitDateTimeWidget(MultiWidget):
    654724    """
    655725    A Widget that splits datetime input into two <input type="text"> boxes.
  • django/core/files/directories.py

     
     1import os
     2
     3def delete_empty_directories(stop_dir, dir):
     4    """
     5    Deletes *empty* directories starting with dir and working it's way up till it hits stop_dir.
     6    """
     7    # normalize paths
     8    stop_dir = os.path.abspath(stop_dir)
     9    dir = os.path.abspath(dir)
     10    if not os.path.exists(stop_dir):
     11        raise Exception("stop_dir ('%s') is not a valid directory." % stop_dir)
     12    try:
     13        while True:
     14            if dir == stop_dir or len(dir) < 4:
     15                return
     16            os.rmdir(dir)
     17            dir = os.path.dirname(dir)
     18    except OSError:
     19        # stop if we have not enough permissions or dir is not empty
     20        pass
  • django/contrib/admin/media/css/forms.css

     
    146146    display: inline !important;
    147147}
    148148
     149/* DELETABLE FILE INPUTS */
     150
     151.deletable-file-input {
     152    display: block;
     153    padding-left: 9.818181em; /* should match the 9em that inputs get pushed to the side by labels */
     154}
     155
     156.deletable-file-input-delete-checkbox {
     157    display: block;
     158}
     159
     160.deletable-file-input-delete-checkbox label {
     161    display:inline; float:none;
     162}
     163
    149164/* MONOSPACE TEXTAREAS */
    150165
    151166fieldset.monospace textarea {
  • django/contrib/admin/options.py

     
    105105            kwargs['widget'] = widgets.AdminTextInputWidget
    106106            return db_field.formfield(**kwargs)
    107107   
    108         # For FileFields and ImageFields add a link to the current file.
    109         if isinstance(db_field, models.ImageField) or isinstance(db_field, models.FileField):
    110             kwargs['widget'] = widgets.AdminFileWidget
    111             return db_field.formfield(**kwargs)
    112 
    113108        # For ForeignKey or ManyToManyFields, use a special widget.
    114109        if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
    115110            if isinstance(db_field, models.ForeignKey) and db_field.name in self.raw_id_fields:
  • django/contrib/admin/widgets.py

     
    8080class AdminRadioSelect(forms.RadioSelect):
    8181    renderer = AdminRadioFieldRenderer
    8282
    83 class AdminFileWidget(forms.FileInput):
    84     """
    85     A FileField Widget that shows its current value if it has one.
    86     """
    87     def __init__(self, attrs={}):
    88         super(AdminFileWidget, self).__init__(attrs)
    89 
    90     def render(self, name, value, attrs=None):
    91         output = []
    92         if value and hasattr(value, "url"):
    93             output.append('%s <a target="_blank" href="%s">%s</a> <br />%s ' % \
    94                 (_('Currently:'), value.url, value, _('Change:')))
    95         output.append(super(AdminFileWidget, self).render(name, value, attrs))
    96         return mark_safe(u''.join(output))
    97 
    9883class ForeignKeyRawIdWidget(forms.TextInput):
    9984    """
    10085    A Widget for displaying ForeignKeys in the "raw_id" interface rather than
  • docs/topics/forms/modelforms.txt

     
    382382Chances are these notes won't affect you unless you're trying to do something
    383383tricky with subclassing.
    384384
     385Making ``FileField``s deletable
     386===============================
     387
     388``files_add_delete_option``
     389---------------------------
     390
     391This option is ``True`` by default.
     392
     393When set to ``True``, all ``FileField``s get delete checkboxes if they have the
     394option ``blank=True``. The current file is also displayed above the field, regardless
     395of the value of ``blank``.
     396
     397``files_delete_empty_dirs``
     398---------------------------
     399
     400This option is ``False`` by default.
     401
     402When set to ``True``, Django removes *empty* directories after each file is deleted. It
     403starts with the directory of the file and then moves it's way up till it reaches the
     404``MEDIA_ROOT`` folder. **Warning**: This may delete more directories than you want.
     405Use with caution!
     406
     407For example, if Django deletes the following file:
     408
     409    /media-root/foo/bar/file.png
     410   
     411It will first try to delete ``/media-root/foo/bar/`` and then ``/media-root/foo/``.
     412
     413``files_delete_from_disk``
     414---------------------------
     415
     416This option is ``True`` by default.
     417
     418When set to ``True``, files of ``FileField`` are deleted from the disk. This happens
     419when a FileField is updated or when it is cleared. The latter is only possible if
     420``files_add_delete_option`` is ``True``.
     421
    385422.. _model-formsets:
    386423
    387424Model Formsets
Back to Top