Ticket #7048: DeletableFileFields-00_code.diff

File DeletableFileFields-00_code.diff, 17.7 KB (added by , 11 years ago)

First version of the patch, no test fixes yet, docs are separate

  • django/forms/models.py

     
    66from django.utils.translation import ugettext_lazy as _
    77from django.utils.encoding import smart_unicode
    88from django.utils.datastructures import SortedDict
     9from django.core.files.uploadedfile import UploadedFile
     10from django.core.files import directories
    911
    1012from util import ValidationError, ErrorList
    1113from forms import BaseForm, get_declared_fields
    12 from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
     14from fields import Field, ChoiceField, IntegerField, FileField, DeletableFileField, EMPTY_VALUES
    1315from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
    1416from widgets import media_property
    1517from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME
    1618
     19import os
     20
    1721__all__ = (
    1822    'ModelForm', 'BaseModelForm', 'model_to_dict', 'fields_for_model',
    1923    'save_instance', 'form_for_fields', 'ModelChoiceField',
    2024    'ModelMultipleChoiceField',
    2125)
    2226
     27def delete_file_field_file(file_field, model_instance, delete_from_disk=False, delete_empty_directories=False):
     28    """
     29    Deletes the file of a model instances FileField. But only if no other
     30    instances are pointing to it and it is not the default.
     31    """
     32    file_obj = getattr(model_instance, file_field.attname)
     33    if not file_obj:
     34        return
     35   
     36    # check whether we need to delete the file from disk or just clear the field
     37    if delete_from_disk:
     38        # create a queryset to determine if the file is being referenced
     39        # by *another* instance.
     40        queryset = model_instance.__class__._default_manager.\
     41            filter(**{file_field.attname: file_obj.name}).\
     42            exclude(pk=model_instance.pk)
     43        # delete the file if is not referenced elsewhere and is not the default
     44        if not queryset and file_obj.name != file_field.default:
     45            file_path = file_obj.path
     46            file_obj.delete(save=False)
     47            if delete_empty_directories:
     48                from django.conf import settings
     49                directories.delete_empty_directories(stop_dir=settings.MEDIA_ROOT, dir=os.path.dirname(file_path))
     50           
     51    # TODO: look into this more. initially it was using None, but that
     52    # was not working as the model field is null=False since there
     53    # seems to be no reason why when the database field is a varchar.
     54    setattr(model_instance, file_field.attname, u"")
     55
     56
    2357def save_instance(form, instance, fields=None, fail_message='saved',
    2458                  commit=True):
    2559    """
     
    4074            continue
    4175        if fields and f.name not in fields:
    4276            continue
    43         f.save_form_data(instance, cleaned_data[f.name])
     77        cleaned_field_data = cleaned_data[f.name]
     78        # if this fields form field is a DeletableFileField instance we may need to delete the file
     79        delete_file = False
     80        if isinstance(form.fields[f.name], DeletableFileField):
     81            # unpack the actual data and the delete checkbox value
     82            cleaned_field_data, delete_file = cleaned_field_data
     83        # replace file
     84        if isinstance(cleaned_field_data, UploadedFile):
     85            # TODO: if a user is uploading a file and checked the box that will
     86            # call delete_file twice. 1) to delete the current file and 2) to
     87            # delete the just uploaded file. maybe make this more clear or have a
     88            # better sensible default.
     89            delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
     90        f.save_form_data(instance, cleaned_field_data)
     91        if delete_file:
     92            delete_file_field_file(f, instance, form._meta.files_delete_from_disk, form._meta.files_delete_empty_dirs)
     93           
    4494    # Wrap up the saving of m2m data as a function.
    4595    def save_m2m():
    4696        opts = instance._meta
     
    150200        self.model = getattr(options, 'model', None)
    151201        self.fields = getattr(options, 'fields', None)
    152202        self.exclude = getattr(options, 'exclude', None)
     203        self.files_add_delete_option = getattr(options, 'files_add_delete_option', True)
     204        self.files_delete_from_disk = getattr(options, 'files_delete_from_disk', True)
     205        self.files_delete_empty_dirs = getattr(options, 'files_delete_empty_dirs', False)
    153206
    154 
    155207class ModelFormMetaclass(type):
    156208    def __new__(cls, name, bases, attrs):
    157209        formfield_callback = attrs.pop('formfield_callback',
     
    177229            # Override default model fields with any custom declared ones
    178230            # (plus, include all the other declared fields).
    179231            fields.update(declared_fields)
     232           
     233            if opts.files_add_delete_option:
     234                # wrap form FileFields in DeletableFileFields to show current file and get delete checkboxes (but only if blank=True)
     235                for field_name, field in fields.items():
     236                    if isinstance(field, FileField):
     237                        fields[field_name] = DeletableFileField(file_field_to_wrap=field, show_delete_checkbox=opts.model._meta.get_field(field_name).blank)
    180238        else:
    181239            fields = declared_fields
    182240        new_class.declared_fields = declared_fields
     
    219277    __metaclass__ = ModelFormMetaclass
    220278
    221279def modelform_factory(model, form=ModelForm, fields=None, exclude=None,
    222                        formfield_callback=lambda f: f.formfield()):
     280                       formfield_callback=lambda f: f.formfield(), **kwargs):
    223281    # HACK: we should be able to construct a ModelForm without creating
    224282    # and passing in a temporary inner class
    225283    class Meta:
     
    227285    setattr(Meta, 'model', model)
    228286    setattr(Meta, 'fields', fields)
    229287    setattr(Meta, 'exclude', exclude)
     288    # only add the options to the Meta class that are actually passed in via kwargs as the defaults are already in ModelFormOptions!
     289    for attr in ('files_add_delete_option', 'files_delete_from_disk', 'files_delete_empty_dirs'):
     290        if attr in kwargs:
     291            setattr(Meta, attr, kwargs[attr])
    230292    class_name = model.__name__ + 'Form'
    231293    return ModelFormMetaclass(class_name, (form,), {'Meta': Meta,
    232294                              'formfield_callback': formfield_callback})
  • django/forms/fields.py

     
    2727from django.utils.encoding import smart_unicode, smart_str
    2828
    2929from util import ErrorList, ValidationError
    30 from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput
     30from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, DeletableFileInput, DeleteCheckboxInput
    3131from django.core.files.uploadedfile import SimpleUploadedFile as UploadedFile
    3232
    3333__all__ = (
     
    3535    'DEFAULT_DATE_INPUT_FORMATS', 'DateField',
    3636    'DEFAULT_TIME_INPUT_FORMATS', 'TimeField',
    3737    'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField',
    38     'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField',
     38    'RegexField', 'EmailField', 'FileField', 'DeletableFileField', 'ImageField', 'URLField',
    3939    'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField',
    4040    'ComboField', 'MultiValueField', 'FloatField', 'DecimalField',
    4141    'SplitDateTimeField', 'IPAddressField', 'FilePathField',
     
    519519            f.seek(0)
    520520        return f
    521521
     522class DeletableFileField(Field):
     523    """
     524    A Field that adds a delete checkbox to a FileField and shows the current value. file_field_to_wrap is the
     525    FileField subclass or its instance to be wrapped. If show_delete_checkbox is set to False the field just
     526    displays the current value and no delete checkbox. It returns a tupel with the actual FileField value and
     527    the state of the delete checkbox.
     528    """
     529    def __init__(self, file_field_to_wrap=FileField, show_delete_checkbox=True, *args, **kwargs):
     530        self.fields = (
     531            isinstance(file_field_to_wrap, type) and file_field_to_wrap() or file_field_to_wrap, # instantiate if it is a class
     532            BooleanField(widget=DeleteCheckboxInput, required=False),
     533        )
     534        # get widgets from the fields and pass them to our MultiWidget
     535        defaults = {
     536            'widget': DeletableFileInput([f.widget for f in self.fields], show_delete_checkbox=show_delete_checkbox),
     537            'required': self.fields[0].required, # this is just for display purposes, the actual check is performed by the FileField
     538        }
     539        defaults.update(kwargs)
     540        super(DeletableFileField, self).__init__(*args, **defaults)
     541   
     542    def clean(self, value, initial=None):
     543        return (self.fields[0].clean(value[0], initial), self.fields[1].clean(value[1]))
     544
    522545url_re = re.compile(
    523546    r'^https?://' # http:// or https://
    524547    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
     
    211211            # widgets split data over several HTML fields.
    212212            value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
    213213            try:
    214                 if isinstance(field, FileField):
     214                if isinstance(field, (FileField, DeletableFileField)):
    215215                    initial = self.initial.get(name, field.initial)
    216216                    value = field.clean(value, initial)
    217217                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 util import flatatt
    2022from urlparse import urljoin
    2123
    2224__all__ = (
    2325    'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput',
    24     'HiddenInput', 'MultipleHiddenInput',
    25     'FileInput', 'DateTimeInput', 'Textarea', 'CheckboxInput',
     26    'HiddenInput', 'MultipleHiddenInput', 'FileInput', 'DateTimeInput',
     27    'Textarea', 'CheckboxInput', 'DeletableFileInput', 'DeleteCheckboxInput',
    2628    'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect',
    2729    'CheckboxSelectMultiple', 'MultiWidget', 'SplitDateTimeWidget',
    2830)
     
    638640        return media
    639641    media = property(_get_media)
    640642   
     643class DeletableFileInput(MultiWidget):
     644    """
     645    A MultiWidget for the use with the DeletableFileField. The contained widgets are
     646    specified by the fields constructor. If the value of the model FileField is None or
     647    UploadedFile the rendered widget of the form FileField is just passed through.
     648    """
     649    def __init__(self, widgets, attrs=None, show_delete_checkbox=True):
     650        self.show_delete_checkbox = show_delete_checkbox
     651        super(DeletableFileInput, self).__init__(widgets, attrs)
     652   
     653    def render(self, name, value, attrs=None):
     654        self.show_checkbox = False
     655        return super(DeletableFileInput, self).render(name, value, attrs)
     656       
     657    def decompress(self, value):
     658        # decompress() is only called when we have initial data (not POST data)
     659        # so we can be sure we have a proper File object here
     660        if value:
     661            self.show_checkbox = True
     662            self.current_file = value
     663            return [value, False]
     664        # the value for DeleteCheckboxInput doesn't really matter as we won't show it anyway
     665        return [None, None]
     666   
     667    def format_output(self, rendered_widgets):
     668        """
     669        Adds a link to the current file and wraps everything in spans to afford formatting.
     670        """
     671        # this is a bit strange as we to do the actual test in decompress()
     672        # but we don't have the value of the model FileField here so ...
     673        if not self.show_checkbox:
     674            return rendered_widgets[0]
     675        # current file
     676        output = [u'<span class="%s"><span class="%s">%s <a target="_blank" href="%s">%s</a></span><br/>' % (
     677            u'deletable-file-input',
     678            u'deletable-file-input-current',
     679            _('Currently:'),
     680            self.current_file.url,
     681            self.current_file,
     682        )]
     683        # file input
     684        output.append('<span class="%s">%s %s</span><br/>' % (
     685            u'deletable-file-input-file-field',
     686            _('Change:'),
     687            rendered_widgets[0], # the FileFields widget
     688        ))
     689        # delete checkbox
     690        if self.show_delete_checkbox:
     691            output.append('<span class="%s">%s</span>' % (
     692                u'deletable-file-input-delete-checkbox',
     693                rendered_widgets[1], # the checkbox
     694            ))
     695        output.append('</span>')
     696        return u''.join(output)
     697
     698class DeleteCheckboxInput(CheckboxInput):
     699    """
     700    A CheckboxInput with a delete label next to it. It only renders if value is not None.
     701    """
     702    def render(self, name, value, attrs=None):
     703        if value == None:
     704            return u''
     705        return u'%s <label for="%s">%s</label>' % (
     706            super(DeleteCheckboxInput, self).render(name, value, attrs),
     707            attrs["id"],
     708            _("Delete"),
     709        )
     710
    641711class SplitDateTimeWidget(MultiWidget):
    642712    """
    643713    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

     
    4141fieldset.collapsed h2 { background-image:url(../img/admin/nav-bg.gif); background-position:bottom left; color:#999; }
    4242fieldset.collapsed .collapse-toggle { padding:3px 5px !important; background:transparent; display:inline !important;}
    4343
     44/* DELETABLE FILE INPUTS */
     45
     46.deletable-file-input { display: block; padding-left: 9.818181em} /* should match the 9em that inputs get pushed to the side by labels */
     47.deletable-file-input-delete-checkbox { display: block; }
     48.deletable-file-input-delete-checkbox label { display:inline; float:none; }
     49
    4450/* MONOSPACE TEXTAREAS */
    4551fieldset.monospace textarea { font-family:"Bitstream Vera Sans Mono",Monaco,"Courier New",Courier,monospace; }
    4652
  • django/contrib/admin/options.py

     
    9999            kwargs['widget'] = widgets.AdminTextInputWidget
    100100            return db_field.formfield(**kwargs)
    101101   
    102         # For FileFields and ImageFields add a link to the current file.
    103         if isinstance(db_field, models.ImageField) or isinstance(db_field, models.FileField):
    104             kwargs['widget'] = widgets.AdminFileWidget
    105             return db_field.formfield(**kwargs)
    106 
    107102        # For ForeignKey or ManyToManyFields, use a special widget.
    108103        if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
    109104            if isinstance(db_field, models.ForeignKey) and db_field.name in self.raw_id_fields:
  • django/contrib/admin/widgets.py

     
    7575class AdminRadioSelect(forms.RadioSelect):
    7676    renderer = AdminRadioFieldRenderer
    7777
    78 class AdminFileWidget(forms.FileInput):
    79     """
    80     A FileField Widget that shows its current value if it has one.
    81     """
    82     def __init__(self, attrs={}):
    83         super(AdminFileWidget, self).__init__(attrs)
    84        
    85     def render(self, name, value, attrs=None):
    86         output = []
    87         if value and hasattr(value, "url"):
    88             output.append('%s <a target="_blank" href="%s">%s</a> <br />%s ' % \
    89                 (_('Currently:'), value.url, value, _('Change:')))
    90         output.append(super(AdminFileWidget, self).render(name, value, attrs))
    91         return mark_safe(u''.join(output))
    92 
    9378class ForeignKeyRawIdWidget(forms.TextInput):
    9479    """
    9580    A Widget for displaying ForeignKeys in the "raw_id" interface rather than
Back to Top