Irrational Exuberance!

Technical Aspects of the Life Flow Editor

February 3, 2008. Filed under djangolifeflowlifeflow-editor

I have spent a lot of time over the past few days implementing the LifeFlow editor, and it has at least as much more work to go, but its shaping up into what I wanted it to be. It has a few interesting tidbits worth mentioning.

Generically Updating Models Via Ajax

One of the biggest pieces of functionality I use in the LFE is sending updates to the models in the background. This means I can keep things up-to-date without explicitly POSTing forms, and makes for a generally less intrusive editing process, while making it less likely to lose data. Among other things you never run into the event where something goes awry and you lose data due when a save fails (since the saves are incremental instead of monolithic).

In a previous Django app I was working on I ended up writing a fully generic updating function that introspected on the models to discover the type of the field that was being saved, and then dealt with it accordingly.

The code was rather complex in order to cope with all of that genericness. Below are the relevant snippets, but feel free to skim it instead of reading closely. (Reading the code example may be easier knowing that I was posting a json serialized dict of contents to the key 'json' in the POST. I wouldn't necessarily take that approach again redoing the code, but it worked well enough despite sounding awkward at first.)

That ended up looking like this:

def process_json_params(request):
    'Parses received JSON into a Python dict, or returns error message.'
    if request.method == "POST" and request.POST.has_key('json'):
        return simplejson.loads(request.POST[u'json'])
    else:
        return None

def retrieve_by_id(request, model):
    'Attempt to retrieve a model instance by id.'
    params = process_json_params(request)
    if params is not None and params.has_key('id'):
        id = params['id']
        try:
            object = model.objects.get(id=id)
            return True, object, params
        except ObjectDoesNotExist:
            return None, "ID refers to a non-existant instance.", params
    else:
        return None, "Did not specify valid ID.", params

def locate_class_by_string(string, module=project.models):
    '''
    Return a Class object from a module based on string.
    Expected input: 'case', 'event, 'email_address'
    '''
    def cap(str):
        return str.capitalize()
    def combine(a, b):
        return "%s_%s" % (a, b)
    capitalized_string = reduce(combine ,map(cap, string.split('_')))
    return getattr(module, capitalized_string)


def get_field(model, field_str):
    for field in model._meta.fields:
        if field.name == field_str:
            return field

def generic_update(request, model):
    'Abstracted update operation for the API.'
    model = locate_class_by_string(model)
    status, obj_or_msg, params = retrieve_by_id(request, model)
    if status is True:
        try:
            for key in params.keys():
                val = params[key]
                f = get_field(model, key)
                if f.__class__ == fields.related.ForeignKey:
                    val = f.rel.to.objects.get(pk=val)
                setattr(obj_or_msg, key, val)
            obj_or_msg.save()
            data = simplejson.dumps({'successful':True})
            return HttpResponse(data, mimetype='application/json')
        except:
            msg = "Improper parameters for updating a %s." % model
            return improper_api_request(msg)
    else:
        return improper_api_request(obj_or_msg)

Phew. That was a very complex way of dealing with it, necessitated by the fact that it had to be very generic to deal with a variety of inconsistent models. Fortunately it was a lot simpler to implement the needed update function for the LifeFlow editor:

BOOLEAN_FIELDS = ["send_ping", "allow_comments", "use_markdown"]
MANY_TO_MANY_FIELDS = ["flows", "tags", "series", "authors"]

@login_required
def update(request):
    dict = request.POST.copy()
    print dict
    id = dict.pop('pk')[0]
    model = dict.pop('model')[0]
    if model == u"draft":
        object = Draft.objects.get(pk=id)
    else:
        object = Entry.objects.get(pk=id)
    obj_dict = object.__dict__
    for key in dict.keys():
        if obj_dict.has_key(key):
            val = dict[key]
            if key in BOOLEAN_FIELDS:
                if val == u"true":
                    val = True
                elif val == u"false":
                    val = False
            obj_dict[key] = val
        elif key in MANY_TO_MANY_FIELDS:
            vals = dict.getlist(key)
            manager = getattr(object, key)
            manager.clear()
            manager.add(*vals)
    object.save()
    return HttpResponse("success")

Because I was only dealing with one model (well, actually two models, the Draft and Entry models, but they overlap perfectly, so I could ignore that they are not the same), I was able to deal without introspecting on the fields themselves, which is a good bit less messy. Also its a convenient trick that I can use the login_required decorator to restrict access to the ajax calls.

Highlighting Already Select Tags/Flows/Series

One thing that took me a few moments to think about was how I would pre-select all already selected tags. This is an important visual cue, but fortunately it was pretty easy to handle.

First I determined which items are already selected via a simple list comprehension in the Python views:

@login_required
def edit_four(request, category, id):
    if category == "entry":
        obj = Entry.objects.get(pk=id)
    else:
        obj = Draft.objects.get(pk=id)
    obj_tags = obj.tags.all()
    tags = [ (x, x in obj_tags) for x in Tag.objects.all()]
    return render_to_response('lifeflow/editor/edit_four.html',
                              {'object':obj,
                               'tags':tags,
                               'model':category},
                              RequestContext(request, {}))

and then I display that visually using this template snippet:

@@ django+html

{% for tag, selected in tags %} {{ tag.title }} {% endfor %}
@@

Pretty simple. Then here is the javascript I use to send the update to the server (using JQuery). I don't use any explicit save buttons, and instead it pushes out the update each time the mouse enters and leaves the div containing.

$(document).ready(function(){$("#selectables").hover(
  function() { ;},
  function() {
    var selected = $.map($("a.selected"),function(x) {
      return x.id;
    });
    jQuery.ajax({type:"POST", url:'/editor/update/',
      data:{"pk":{{ object.pk }}, "model":"{{ model }}" ,"tags":selected}});
    }
)});

Notice that the hover function takes two parameters, the first is the function to call when the mouse moves into its space, and the second when the mouse leaves its space. I give it a do-nothing anonymous function for the first parameter, since I don't want it to do anything when the mouse enters the area.

And for good measure, here is the code I use to toggle the highlighting of the selectable items:

$(document).ready(function() {
  $("a.selectable").click(function () {
    $(this).toggleClass("selected");
})});

Converting Entries to Drafts and Vice Versa

One thing the code ends up doing a lot is converting between the Draft and Entry models a lot. The two models are mostly the same, with the Entry being a superset of Draft, except that Draft contains the field edited that Entry does not contain.

I do most of the work here by grabbing the fields dict from the models and stripping out unnecessarily fields, and adding in anything missing.

Here is converting a Draft into an Entry:

@login_required
def edited_to_published(request, id):
    def slugify(str):
        return str.lower().replace(' ','-')[:95]
    def prepare_draft(draft):
        dict = draft.__dict__.copy()
        if dict['pub_date'] is None:
            dict['pub_date'] = datetime.datetime.now()
        del dict['edited']
        if dict['slug'] is None:
            dict['slug'] = slugify(dict['title'])
        return dict
    try:
        obj = Draft.objects.get(pk=id)
        pub = Entry(**prepare_draft(obj))
        pub.save()
        obj.delete()
        return HttpResponse(pub.pk)
    except TypeError:
        return HttpResponseServerError(u"Missing required fields.")
    except:
        return HttpResponseServerError(u"Update failed.")

Its a bit simpler to convert Entry instances into Drafts:

@login_required
def published_to_edited(request, id):
    def convert_to_draft(entry):
        dict = entry.__dict__.copy()
        dict['edited'] = True
        del dict['body_html']
        return dict
    try:
        entry = Entry.objects.get(pk=id)
        draft = Draft(**convert_to_draft(entry))
        draft.save()
        entry.delete()
        return HttpResponse(draft.pk)
    except:
        return HttpResponseServerError(u"Update failed.")

Dynamically Rendering Markdown Formatted Text

Here is the view that handles rendering formatted text. If a model and id are specified then it simply uses the body of their text, but otherwise it will render the contents of the 'txt' parameter in the POST dict. The dbc_markup function is short for Dynamic Blog Context markup, and applies code syntax highlighting, Markdown formatting, and the LifeFlow specific Dynamic Blog Context extensions.

@login_required
def render(request, model=None, id=None):
    if id is None:
        txt = dbc_markup(request.POST['txt'])
    else:
        if model == u"draft":
            obj = Draft.objects.get(pk=id)
        elif model ==u"entry":
            obj = Entry.objects.get(pk=id)
        if obj.use_markdown:
            txt = dbc_markup(obj.body, obj)
        else:
            txt = obj.body
    return HttpResponse(txt)

This is pretty handy, despite being very simple.

Anyway, there are probably a few other interesting snippets worth looking at in the LifeFlow Editor code, but a lot of it is still undergoing work and is a bit unprofessional to look at at this exact moment.

Let me know if anyone has any complaints about the style or details of the implementations. Some of the choices were influenced by this being a backend tool that doesn't need to be hugely safe (from dangerous input, certainly it can't be exposed to dangerous users). But it still has a long way to go.