I was given a task to create items in Django models and show them in order that the user defines. As the user updates the order, it needs to update real-time in the database. The order should also be preserved when the user comes back to the page next time. Ordering of items is not supported in Django models (or in relational database to begin with).
Pre-requisite knowledge (please do a search for these if you don't know what they are ^-^ ):
- Python
- Django
- jQuery
I've found a few links that mention how to do this kind of ordering with Django.
http://www.djangosnippets.org/snippets/1053/
http://www.djangosnippets.org/snippets/1489/
However, They only show how to do it with Django admin page, and they don't update the database in a real-time manner (the form is submitted manually by the user at the end). So I had to adapt their approaches.
First of all, The above links use 'TabularInline' and 'StackedInline' which only exist in Django admin module, and I don't want to be tied to the admin module as this is not going to be in the admin page. So I did my trick of searching through Django modules for the word 'inline' :P, and found this 'inlineformset_factory' function in django.forms.models module... Aha! this is probably what I'm looking for.
So I created my models:
class ProjectSandbox(models.Model):
"""
This acts like the container of the ordered items.
"""
project = models.CharField(_('Project'), max_length=512) class Meta:
verbose_name_plural = _('ProjectSandboxes')
class ProjectSandboxItem(models.Model):
"""
These are the items I want to order.
"""
sandbox = models.ForeignKey(ProjectSandbox)
story = models.CharField(_('Story'),max_length=512)
position = models.PositiveIntegerField(_('position'), default=0)
class Meta:
ordering = ('position',)
Then, my forms:
class SandboxItemForm(forms.ModelForm):
sandbox = forms.IntegerField(widget=forms.HiddenInput)
story = forms.CharField(widget=forms.HiddenInput)
position = forms.IntegerField(widget=forms.HiddenInput)
class Meta:
model = ProjectSandboxItem
class Media:
js = (
settings.MEDIA_URL+'js/external/jquery/ui/ui.core.js',
settings.MEDIA_URL+'js/external/jquery/ui/ui.sortable.js',
settings.MEDIA_URL+'js/projectapp/story_order.js',
)
css = {
'all': (
settings.MEDIA_URL+'css/external/jquery/ui.all.css',
settings.MEDIA_URL+'css/projectapp/sandbox.css',
)
}
SandboxForm = inlineformset_factory(ProjectSandbox,ProjectSandboxItem,form=SandboxItemForm,extra=0,can_delete=False)
'SandboxItemForm' is just a ModelForm for 'ProjectSandboxItem' that I override the fields to use hidden input instead since I don't want to edit anything in this page. I just want to order them.
'SandboxForm' is the formset of 'SandboxItemForm' using the 2 models relationship to select the set. 'extra' parameter is set to 0, so Django doesn't give me extra empty forms, and 'can_delete' is set to False so Django doesn't give me the delete checkbox for each item. Again, I don't want to edit anything, I just want to order them.
And here comes the view function:
def project_prioritization(request, abbreviation):
context = RequestContext(request)
project = get_object_or_404(Project, abbreviation=abbreviation)
sandbox = ProjectSandbox.objects.get(project=project.get_absolute_url())
stories = project.get_all_stories()
story_map = {}
for story in stories:
story_map[story.get_absolute_url()] = story
context['story_map'] = story_map
if request.method == 'POST':
formset = SandboxForm(request.POST,instance=sandbox)
if not formset.is_valid():
context['errors'] = formset.errors
context['non_form_errors'] = formset.non_form_errors()
context['formset'] = formset
return HttpResponseForbidden(render_to_response('prioritization.html',context))
formset.save()
context['formset'] = SandboxForm(instance=sandbox)
return render_to_response('prioritization.html',context)
Then, in the template, I just loop through all the forms in the formset:
{{formset.management_form}}
{% for form in formset.forms %}
{% with form.initial.story as story_url %}
{% with story_map|hash:story_url as story %}
<div class="portlet">
<div class="portlet-header">{{story.name}}</div>
<div class="portlet-content">{{story.context}}</div>
<div style="display:none;" id="sandboxItem-{{formset.forms|find_index:form}}">
{{form}}
</div>
</div>
{% endwith %}
{% endwith %}
{% endfor %}
The actual form is not displayed to the user but used for submitting. 'hash' and 'find_index' are template filter functions which I wrote because built-in template filters cannot give me the information I want out of the variables. The one to pay more attention to is the 'find_index' function which gets me the index of the form in the formset.forms list. This will play an important role later.
If you've noticed from above, my 'SandboxItemForm' has Media class which should be put in the header part of the template as {{formset.media}}, so that the rendered html will include all the necessary js and css files.
I won't be talking much about the css files because it's only used to make the page pretty, and I took it out of jQuery example :P
The js file that does the ordering functionality is shown below:
position_field = 'position';
jQuery(function($) {
// make div with the class sandbox sortable
$(".sandbox").sortable({
update: function() {
update_positions($(this));
}
});
// adjust the style with ui classes
$(".portlet").addClass("ui-widget ui-widget-content ui-helper-clearfix ui-corner-all")
.find(".portlet-header")
.addClass("ui-widget-header ui-corner-all")
.prepend('<span class="ui-icon ui-icon-plusthick"></span>')
.end()
.find(".portlet-content");
$(".portlet-header .ui-icon").click(function() {
$(this).toggleClass("ui-icon-minusthick");
$(this).parents(".portlet:first").find(".portlet-content").toggle();
});
// change the cursor to move cursor, so the user knows that this can be moved
$(".portlet").css('cursor', 'move');
});
// Updates "position"-field values based on row position in sandbox
function update_positions(sandbox) {
position = 0;
// Set correct position: Filter through all portlet
$(".portlet").each(function() {
if (position_field != '') {
// Update position field
$(this).find('input[id$="-'+position_field+'"]').val(position + 1);
position++;
}
});
// submit form to make the change in the database
$.ajax({
type: $("#sandboxForm").attr('method'),
url: $("#sandboxForm").attr('action'),
data: $("#sandboxForm").serialize(),
error: function(response){
// find error paragraph, and update the error
error_p = $(response.responseText).find('div[class="errorbox-inside ui-state-error ui-corner-all"]').find('p');
old_error_p = $(".ui-state-error").find('p');
old_error_p.html(error_p.html());
$(".errorbox").show();
// need to update id for formset
update_formset_ids(response.responseText);
},
success: function(response){
// if errorbox was shown, hide it
if ($(".errorbox").is(':visible')){
$(".errorbox").hide();
}
// need to update id for formset
update_formset_ids(response);
}
});
}
function update_formset_ids(response){
prefix = "sandboxItem-"
$(".sandbox").find('div[id^="'+prefix+'"]').each(function(){
// the form item that is already shown in the page.
old_sandbox_item = $(this);
old_formset_order = old_sandbox_item.attr('id').split(prefix)[1];
old_id_input = $(old_sandbox_item).find('input[id$="-id"]');
old_id = old_id_input.val();
// find new formset order with this id from the response
new_id_input = $(response).find('input[id$="-id"][value="'+old_id+'"]');
new_sandbox_item = new_id_input.parent();
formset_order = new_sandbox_item.attr('id').split(prefix)[1];
if (formset_order != old_formset_order){
// if the order number is different we need to update the ids and the names of the form items, so that we sync up with the order retrieved from the database
old_sandbox_item.attr({'id':prefix + formset_order});
sandboxitem_prefix = "id_projectsandboxitem_set-";
old_sandbox_item.find('[id^='+sandboxitem_prefix+']').each(function(){
input = $(this);
input_id_array = input.attr('id').split('-');
input_name_array = input.attr('name').split('-');
input.attr({'id':input_id_array[0]+"-"+formset_order+"-"+input_id_array[2],'name':input_name_array[0]+"-"+formset_order+"-"+input_name_array[2]});
});
}
});
}
'position_field' contains the name of the model field that we use for ordering.
$(".sandbox").sortable is jQuery UI plugin to make the content in element, in this case element with the class 'sandbox', sortable.
The function in the update event is called every time there's an update, which in this code will call another function called 'update_positions'.
'update_positions' will loop through all the element with 'portlet' class and update the 'position' field to the new order. Then, it will do an ajax call to the server to make the position change in the database.
The below work is now taken care by Django 1.1 release version. I was using the Beta version *sigh*
At first, I thought my work is done here. Nope.
Because we're using the ajax call and not refreshing the page (why oh why do they need to update in real-time T-T), the order of the forms that we have in our client page does not sync with the ones that would be generated by the 'inlineformset_factory' the next time since the database ordering has changed.
What this means is that when the form is submitted the second time the running id of some forms will not match the running id that the formset expects as we do this:
formset = SandboxForm(request.POST,instance=sandbox)
You can see the running id of the form when you view source of the page in the browser, for example, below '2' is the running id where the item itself has an 'id' or 'pk' in the database as '11':
<div id="sandboxItem-2" style="display: none;">
<input id="id_projectsandboxitem_set-2-sandbox" type="hidden" value="1" name="projectsandboxitem_set-2-sandbox"/>
<input id="id_projectsandboxitem_set-2-story" type="hidden" value="/story/TST-11/" name="projectsandboxitem_set-2-story"/>
<input id="id_projectsandboxitem_set-2-position" type="hidden" value="3" name="projectsandboxitem_set-2-position"/>
<input id="id_projectsandboxitem_set-2-id" type="hidden" value="11" name="projectsandboxitem_set-2-id"/>
</div>
When the running id does not sync with the ones retrieved from the updated database and you submit the form, this is what you'll get:
[{}, {}, {}, {'id': [u'Project sandbox item with this None already exists.']}, {'id': [u'Project sandbox item with this None already exists.']}, {'id': [u'Project sandbox item with this None already exists.']}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]
The list is the error list for each form. The ones that their ids don't match will receive the error 'Project sandbox item with this None already exists.'.
This is where 'update_formset_ids' function comes in. This takes the response that comes back from the ajax call, which returns html rendered with the same template file, and finds all the 'sandboxItem-' divs (I told you it would become important) to update the running id of the form fields if it has been updated.
The video below will show that every time there's an update, an ajax call is made (see the Firebug area at the bottom), and you can also open the page in a new window/tab and receive the same order.
Now, my work is really done!
(well... apart from having to beautify the page... finish the unit tests... etc... etc...)
