Django

Code

Ticket #7048: DeletableFileFields-00_code.diff

File DeletableFileFields-00_code.diff, 17.7 kB (added by jarrow, 5 months ago)

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

  • django/forms/models.py

    old new  
    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

    old new  
    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

    old new  
    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

    old new  
    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

    old new  
     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

    old new  
    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

    old new  
    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

    old new  
    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