diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py index 13cf13cc4f..9e8b4fb8f4 100644 --- a/django/contrib/admin/__init__.py +++ b/django/contrib/admin/__init__.py @@ -1,2 +1,3 @@ from django.contrib.admin.options import ModelAdmin +from django.contrib.admin.options import StackedInline, TabularInline from django.contrib.admin.sites import AdminSite, site diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index a7d04538fd..b7458134c1 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1,5 +1,7 @@ from django import oldforms, template from django import newforms as forms +from django.newforms.formsets import all_valid +from django.newforms.models import inline_formset from django.contrib.admin import widgets from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.db import models @@ -94,6 +96,77 @@ class BoundField(object): attrs = classes and {'class': ' '.join(classes)} or {} return self.field.label_tag(contents=contents, attrs=attrs) +class InlineOptions(object): + """ + Options for inline editing of ``model`` instances. + + Provide ``name`` to specify the attribute name of the ``ForeignKey`` from + ``model`` to its parent. This is required if ``model`` has more than one + ``ForeignKey`` to its parent. + """ + def __init__(self, model, name=None, extra=3, fields=None, template=None, formfield_callback=lambda f: f.formfield()): + self.model = model + self.name = name + self.extra = extra + self.fields = fields + self.template = template or self.default_template + self.verbose_name = model._meta.verbose_name + self.verbose_name_plural = model._meta.verbose_name_plural + self.prepopulated_fields = {} + self.formfield_callback = formfield_callback + +class StackedInline(InlineOptions): + default_template = 'admin/edit_inline_stacked.html' + +class TabularInline(InlineOptions): + default_template = 'admin/edit_inline_tabular.html' + +class BoundInline(object): + def __init__(self, opts, formset): + self.opts = opts + self.formset = formset + + def __iter__(self): + for form, original in zip(self.formset.change_forms, self.formset.get_inline_objects()): + yield BoundInlineObject(form, original, self.opts) + for form in self.formset.add_forms: + yield BoundInlineObject(form, None, self.opts) + + def fields(self): + # HACK: each form instance has some extra fields. Getting those fields + # from the form class will take some rearranging. Get them from the + # first form instance for now. + return list(self.formset.forms[0]) + + def verbose_name(self): + return self.opts.verbose_name + + def verbose_name_plural(self): + return self.opts.verbose_name_plural + +class BoundInlineObject(object): + def __init__(self, form, original, opts): + self.opts = opts + self.base_form = form + self.form = AdminForm(form, self.fieldsets(), opts.prepopulated_fields) + self.original = original + + def fieldsets(self): + """ + Generator that yields Fieldset objects for use on add and change admin + form pages. + + This default implementation looks at self.fields, but subclasses can + override this implementation and do something special based on the + given HttpRequest object. + """ + if self.opts.fields is None: + default_fields = [f for f in self.base_form.fields] + yield Fieldset(fields=default_fields) + else: + for name, options in self.opts.fields: + yield Fieldset(name, options['fields'], classes=options.get('classes', '').split(' '), description=options.get('description')) + class ModelAdmin(object): "Encapsulates all admin options and functionality for a given model." @@ -292,7 +365,7 @@ class ModelAdmin(object): """ return self.queryset(request) - def save_add(self, request, model, form, post_url_continue): + def save_add(self, request, model, form, formsets, post_url_continue): """ Saves the object in the "add" stage and returns an HttpResponseRedirect. @@ -302,6 +375,14 @@ class ModelAdmin(object): from django.contrib.contenttypes.models import ContentType opts = model._meta new_object = form.save(commit=True) + + if formsets: + for formset in formsets: + # HACK: it seems like the parent obejct should be passed into + # a method of something, not just set as an attribute + formset.instance = new_object + formset.save() + pk_value = new_object._get_pk_val() LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(model).id, pk_value, str(new_object), ADDITION) msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': opts.verbose_name, 'obj': new_object} @@ -331,11 +412,13 @@ class ModelAdmin(object): post_url = '../../../' return HttpResponseRedirect(post_url) - def save_change(self, request, model, form): + def save_change(self, request, model, form, formsets=None): """ Saves the object in the "change" stage and returns an HttpResponseRedirect. `form` is a bound Form instance that's verified to be valid. + + `formsets` is a sequence of InlineFormSet instances that are verified to be valid. """ from django.contrib.admin.models import LogEntry, CHANGE from django.contrib.contenttypes.models import ContentType @@ -343,6 +426,10 @@ class ModelAdmin(object): new_object = form.save(commit=True) pk_value = new_object._get_pk_val() + if formsets: + for formset in formsets: + formset.save() + # Construct the change message. TODO: Temporarily commented-out, # as manipulator object doesn't exist anymore, and we don't yet # have a way to get fields_added, fields_changed, fields_deleted. @@ -394,15 +481,22 @@ class ModelAdmin(object): ModelForm = forms.form_for_model(model, formfield_callback=self.formfield_for_dbfield) + inline_formsets = [] if request.POST: new_data = request.POST.copy() if opts.has_field_type(models.FileField): new_data.update(request.FILES) form = ModelForm(new_data) - if form.is_valid(): - return self.save_add(request, model, form, '../%s/') + for FormSet in self.get_inline_formsets(): + inline_formset = FormSet(data=new_data) + inline_formsets.append(inline_formset) + if form.is_valid() and all_valid(inline_formsets): + return self.save_add(request, model, form, inline_formsets, '../%s/') else: form = ModelForm(initial=request.GET) + for FormSet in self.get_inline_formsets(): + inline_formset = FormSet() + inline_formsets.append(inline_formset) c = template.RequestContext(request, { 'title': _('Add %s') % opts.verbose_name, @@ -410,6 +504,7 @@ class ModelAdmin(object): 'is_popup': request.REQUEST.has_key('_popup'), 'show_delete': False, 'javascript_imports': self.javascript_add(request), + 'bound_inlines': [BoundInline(i, fs) for i, fs in zip(self.inlines, inline_formsets)], }) return render_change_form(self, model, model.AddManipulator(), c, add=True) @@ -439,16 +534,23 @@ class ModelAdmin(object): ModelForm = forms.form_for_instance(obj, formfield_callback=self.formfield_for_dbfield) + inline_formsets = [] if request.POST: new_data = request.POST.copy() if opts.has_field_type(models.FileField): new_data.update(request.FILES) form = ModelForm(new_data) + for FormSet in self.get_inline_formsets(): + inline_formset = FormSet(obj, new_data) + inline_formsets.append(inline_formset) - if form.is_valid(): - return self.save_change(request, model, form) + if form.is_valid() and all_valid(inline_formsets): + return self.save_change(request, model, form, inline_formsets) else: form = ModelForm() + for FormSet in self.get_inline_formsets(): + inline_formset = FormSet(obj) + inline_formsets.append(inline_formset) ## Populate the FormWrapper. #oldform = oldforms.FormWrapper(manipulator, new_data, errors) @@ -463,7 +565,6 @@ class ModelAdmin(object): #related.get_accessor_name()) #orig_list = func() #oldform.order_objects.extend(orig_list) - c = template.RequestContext(request, { 'title': _('Change %s') % opts.verbose_name, 'adminform': AdminForm(form, self.fieldsets_change(request, obj), self.prepopulated_fields), @@ -471,6 +572,7 @@ class ModelAdmin(object): 'original': obj, 'is_popup': request.REQUEST.has_key('_popup'), 'javascript_imports': self.javascript_change(request, obj), + 'bound_inlines': [BoundInline(i, fs) for i, fs in zip(self.inlines, inline_formsets)], }) return render_change_form(self, model, model.ChangeManipulator(object_id), c, change=True) @@ -572,3 +674,10 @@ class ModelAdmin(object): "admin/object_history.html" ] return render_to_response(template_list, extra_context, context_instance=template.RequestContext(request)) + + def get_inline_formsets(self): + inline_formset_classes = [] + for opts in self.inlines: + inline = inline_formset(self.model, opts.model, formfield_callback=self.formfield_for_dbfield, fields=opts.fields, extra=opts.extra) + inline_formset_classes.append(inline) + return inline_formset_classes diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index 27df8436b8..75c645665d 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -63,6 +63,10 @@ {% block after_field_sets %}{% endblock %} +{% for bound_inline in bound_inlines %} + {% render_inline bound_inline %} +{% endfor %} + {% block after_related_objects %}{% endblock %} {% submit_row %} diff --git a/django/contrib/admin/templates/admin/edit_inline_stacked.html b/django/contrib/admin/templates/admin/edit_inline_stacked.html index 48ecc698d9..1955842385 100644 --- a/django/contrib/admin/templates/admin/edit_inline_stacked.html +++ b/django/contrib/admin/templates/admin/edit_inline_stacked.html @@ -1,3 +1,32 @@ +{{ bound_inline.formset.management_form }} +{% for bound_inline_object in bound_inline %} +
+

{{ bound_inline.verbose_name|title }} #{{ forloop.counter }}

+ {% for bfset in bound_inline_object.form %} +
+ {% if bfset.fieldset.name %}

{{ bfset.fieldset.name }}

{% endif %} + {% if bfset.fieldset.description %}
{{ bfset.fieldset.description }}
{% endif %} + {% for line in bfset %} +
+ {{ line.errors }} + {% for field in line %} + {% if field.is_checkbox %} + {{ field.field }}{{ field.label_tag }} + {% else %} + {{ field.label_tag }}{{ field.field }} + {% endif %} + {% if field.field.field.help_text %}

{{ field.field.field.help_text }}

{% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+{% endfor %} + +{% comment %} + + {% load admin_modify %}
{% for fcw in bound_related_object.form_field_collection_wrappers %} @@ -14,3 +43,4 @@ {% endfor %} {% endfor %}
+{% endcomment %} \ No newline at end of file diff --git a/django/contrib/admin/templates/admin/edit_inline_tabular.html b/django/contrib/admin/templates/admin/edit_inline_tabular.html index 3d059c8b3d..1ff7a4bf5f 100644 --- a/django/contrib/admin/templates/admin/edit_inline_tabular.html +++ b/django/contrib/admin/templates/admin/edit_inline_tabular.html @@ -1,3 +1,53 @@ +{{ bound_inline.formset.management_form }} +
+

{{ bound_inline.verbose_name_plural|capfirst|escape }}

+ + + {% for field in bound_inline.fields %} + {% if not field.is_hidden %} + + {% endif %} + {% endfor %} + + + {% for bound_inline_object in bound_inline %} + + + + {% if bound_inline_object.form.form.errors %} + + {% endif %} + + + {% for bfset in bound_inline_object.form %} + {% for line in bfset %} + {% for field in line %} + + {% if not field.field.is_hidden %} + + {% else %} + {{ field.field }} + {% endif %} + {% endfor %} + {% endfor %} + {% endfor %} + + + + + {% endfor %} + +
{{ field.label|capfirst|escape }}
+ {{ bound_inline_object.form.form.errors }} +
{{ field.field }}
+ + + +
+ +{% comment %} + + {% load admin_modify %}

{{ bound_related_object.relation.opts.verbose_name_plural|capfirst|escape }}

@@ -42,3 +92,4 @@ {% endfor %} {% endfor %} +{% endcomment %} \ No newline at end of file diff --git a/django/contrib/admin/templatetags/admin_modify.py b/django/contrib/admin/templatetags/admin_modify.py index ff189a405a..1ba5f8f104 100644 --- a/django/contrib/admin/templatetags/admin_modify.py +++ b/django/contrib/admin/templatetags/admin_modify.py @@ -104,3 +104,20 @@ def field_widget(parser, token): raise template.TemplateSyntaxError, "%s takes 1 argument" % bits[0] return FieldWidgetNode(bits[1]) field_widget = register.tag(field_widget) + +class InlineNode(template.Node): + def __init__(self, inline_var): + self.inline_var = inline_var + + def render(self, context): + inline = context[self.inline_var] + t = loader.get_template(inline.opts.template) + output = t.render(context) + return output + +def render_inline(parser, token): + bits = token.contents.split() + if len(bits) != 2: + raise template.TemplateSyntaxError, "%s takes 1 argument" % bits[0] + return InlineNode(bits[1]) +render_inline = register.tag(render_inline) diff --git a/django/newforms/formsets.py b/django/newforms/formsets.py index d493c04a5d..2e1a8e8767 100644 --- a/django/newforms/formsets.py +++ b/django/newforms/formsets.py @@ -1,18 +1,20 @@ -from django import newforms as forms +from forms import Form, ValidationError +from fields import IntegerField, BooleanField +from widgets import HiddenInput # special field names FORM_COUNT_FIELD_NAME = 'COUNT' ORDERING_FIELD_NAME = 'ORDER' DELETION_FIELD_NAME = 'DELETE' -class ManagementForm(forms.Form): +class ManagementForm(Form): """ ``ManagementForm`` is used to keep track of how many form instances are displayed on the page. If adding new forms via javascript, you should increment the count field of this form as well. """ def __init__(self, *args, **kwargs): - self.base_fields[FORM_COUNT_FIELD_NAME] = forms.IntegerField(widget=forms.HiddenInput) + self.base_fields[FORM_COUNT_FIELD_NAME] = IntegerField(widget=HiddenInput) super(ManagementForm, self).__init__(*args, **kwargs) class BaseFormSet(object): @@ -33,7 +35,7 @@ class BaseFormSet(object): self.change_form_count = self.total_forms - self.num_extra else: # not sure that ValidationError is the best thing to raise here - raise forms.ValidationError('ManagementForm data is missing or has been tampered with') + raise ValidationError('ManagementForm data is missing or has been tampered with') elif initial: self.change_form_count = len(initial) self.required_forms = len(initial) @@ -136,9 +138,9 @@ class BaseFormSet(object): def add_fields(self, form, index): """A hook for adding extra fields on to each form instance.""" if self.orderable: - form.fields[ORDERING_FIELD_NAME] = forms.IntegerField(label='Order', initial=index+1) + form.fields[ORDERING_FIELD_NAME] = IntegerField(label='Order', initial=index+1) if self.deletable: - form.fields[DELETION_FIELD_NAME] = forms.BooleanField(label='Delete', required=False) + form.fields[DELETION_FIELD_NAME] = BooleanField(label='Delete', required=False) def add_prefix(self, index): return '%s-%s' % (self.prefix, index) @@ -151,3 +153,10 @@ def formset_for_form(form, formset=BaseFormSet, num_extra=1, orderable=False, de """Return a FormSet for the given form class.""" attrs = {'form_class': form, 'num_extra': num_extra, 'orderable': orderable, 'deletable': deletable} return type(form.__name__ + 'FormSet', (formset,), attrs) + +def all_valid(formsets): + """Returns true if every formset in formsets is valid.""" + for formset in formsets: + if not formset.is_valid(): + return False + return True \ No newline at end of file diff --git a/django/newforms/models.py b/django/newforms/models.py index 1f783429be..700c9fe33d 100644 --- a/django/newforms/models.py +++ b/django/newforms/models.py @@ -7,8 +7,9 @@ from django.utils.translation import gettext from util import ValidationError from forms import BaseForm, SortedDictFromList -from fields import Field, ChoiceField -from widgets import Select, SelectMultiple, MultipleHiddenInput +from fields import Field, ChoiceField, IntegerField +from formsets import BaseFormSet, formset_for_form, DELETION_FIELD_NAME +from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput __all__ = ( 'save_instance', 'form_for_model', 'form_for_instance', 'form_for_fields', @@ -197,3 +198,132 @@ class ModelMultipleChoiceField(ModelChoiceField): else: final_values.append(obj) return final_values + +# Model-FormSet integration ################################################### + +def initial_data(instance, fields=None): + """ + Return a dictionary from data in ``instance`` that is suitable for + use as a ``Form`` constructor's ``initial`` argument. + + Provide ``fields`` to specify the names of specific fields to return. + All field values in the instance will be returned if ``fields`` is not + provided. + """ + model = instance.__class__ + opts = model._meta + initial = {} + for f in opts.fields + opts.many_to_many: + if not f.editable: + continue + if fields and not f.name in fields: + continue + initial[f.name] = f.value_from_object(instance) + return initial + +class BaseModelFormSet(BaseFormSet): + """ + A ``FormSet`` attatched to a particular model or sequence of model instances. + """ + model = None + + def __init__(self, data=None, auto_id='id_%s', prefix=None, instances=None): + self.instances = instances + kwargs = {'data': data, 'auto_id': auto_id, 'prefix': prefix} + if instances: + kwargs['initial'] = [initial_data(instance) for instance in instances] + super(BaseModelFormSet, self).__init__(**kwargs) + + def save_new(self, form, commit=True): + """Saves and retrutns a new model instance for the given form.""" + return save_instance(form, self.model(), commit=commit) + + def save_instance(self, form, instance, commit=True): + """Saves and retrutns an existing model instance for the given form.""" + return save_instance(form, instance, commit=commit) + + def save(self, commit=True): + """Saves model instances for every form, adding and changing instances + as necessary, and returns the list of instances. + """ + saved_instances = [] + # put self.instances into a dict so they are easy to lookup by pk + instances = {} + for instance in self.instances: + instances[instance._get_pk_val()] = instance + if self.instances: + # update/save existing instances + for form in self.change_forms: + instance = instances[form.cleaned_data[self.model._meta.pk.attname]] + if form.cleaned_data[DELETION_FIELD_NAME]: + instance.delete() + else: + saved_instances.append(self.save_instance(form, instance, commit=commit)) + # create/save new instances + for form in self.add_forms: + if form.is_empty(): + continue + saved_instances.append(self.save_new(form, commit=commit)) + return saved_instances + + def add_fields(self, form, index): + """Add a hidden field for the object's primary key.""" + form.fields[self.model._meta.pk.attname] = IntegerField(required=False, widget=HiddenInput) + super(BaseModelFormSet, self).add_fields(form, index) + +def formset_for_model(model, form=BaseForm, formfield_callback=lambda f: f.formfield(), formset=BaseModelFormSet, extra=1, orderable=False, deletable=False, fields=None): + form = form_for_model(model, form=form, fields=fields, formfield_callback=formfield_callback) + FormSet = formset_for_form(form, formset, extra, orderable, deletable) + FormSet.model = model + return FormSet + +class InlineFormset(BaseModelFormSet): + """A formset for child objects related to a parent.""" + def __init__(self, instance=None, data=None): + self.instance = instance + super(InlineFormset, self).__init__(data, instances=self.get_inline_objects()) + + def get_inline_objects(self): + if self.instance is None: + return [] + from django.db.models.fields.related import RelatedObject + # is there a better way to get the object descriptor? + rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name() + return getattr(self.instance, rel_name).all() + + def save_new(self, form, commit=True): + kwargs = {self.fk.get_attname(): self.instance._get_pk_val()} + new_obj = self.model(**kwargs) + return save_instance(form, new_obj, commit=commit) + +def inline_formset(parent_model, model, fk_name=None, fields=None, extra=3, formfield_callback=lambda f: f.formfield()): + """ + Returns an ``InlineFormset`` for the given kwargs. + + You must provide ``fk_name`` if ``model`` has more than one ``ForeignKey`` + to ``parent_model``. + """ + from django.db.models import ForeignKey + opts = model._meta + # figure out what the ForeignKey from model to parent_model is + if fk_name is None: + fks_to_parent = [f for f in opts.fields if isinstance(f, ForeignKey) and f.rel.to == parent_model] + if len(fks_to_parent) == 1: + fk = fks_to_parent[0] + elif len(fks_to_parent) == 0: + raise Exception("%s has no ForeignKey to %s" % (model, parent_model)) + else: + raise Exception("%s has more than 1 ForeignKey to %s" % (model, parent_model)) + # let the formset handle object deletion by default + FormSet = formset_for_model(model, formset=InlineFormset, fields=fields, formfield_callback=formfield_callback, extra=extra, deletable=True) + # HACK: remove the ForeignKey to the parent from every form + # This should be done a line above before we pass 'fields' to formset_for_model + # an 'omit' argument would be very handy here + try: + del FormSet.form_class.base_fields[fk.name] + except KeyError: + pass + FormSet.parent_model = parent_model + FormSet.fk_name = fk.name + FormSet.fk = fk + return FormSet