Annotations Tutorial: Making our first annotation

In this part of our per-paragraph commenting app, we will design our data models and write test cases and views for a regular POST request.


Creating models from concepts and requirements(Image Credits)

As we said in the last tutorial, we saw how our frontend annotation parameters have been modeled. Now, let us put them in python perspective. A Django Data Model for the required annotation fields would look like:

class Annotation(models.Model):
    PRIVACY_OPTIONS = (
    ( 'public', 0),
    ( 'author', 1),
    ( 'group', 2),
    ('private', 3),
    )
    #Relations with other objects
    content_type = models.ForeignKey(ContentType, 
    verbose_name=_("Content Type"), 
    related_name="content_type_set_for_annotations")
    object_id = models.TextField(_("object ID"))
    content_object = generic.GenericForeignKey(ct_field="content_type", fk_field="object_id")
    #User relevant stuff
    body = models.TextField()
    paragraph = models.PositiveIntegerField(null=False)
    '''
    Annotations can be written only by logged in users. If the user cannot afford 
    to make himself and his interest in reading known, alas, we cannot help him in
    case of making annotations. It is also to prevent hit and run comments by people
    under anonymity.
    '''
    author = models.ForeignKey(User, related_name="author", null=False, blank=False, verbose_name=_("Annotation author"))
    #Privacy settings
    privacy= models.PositiveSmallIntegerField(choices=PRIVACY_OPTIONS, default=PRIVACY_OPTIONS['private'])
    #Privacy reset for Spam protection, if annotation has been shared (and marked as offensive)
    privacy_override = models.BooleanField(default=False)
    #Shared with these users.
    shared_with = models.ManyToManyField(User, through="Annotation_share_map", null="True")
    #Statistics related stuff
    date_created = models.DateTimeField(auto_now_add=True)
    date_modified = models.DateTimeField(auto_now=True)   

Lets write a simple test to create an Annotation object. In our test class, create a new test as:

def test_create_annotation(self):
    annotation = Annotation()
    annotation.content_type="blogging"
    annotation.object_id= str(1)
    annotation.body="This is a test annotation"
    annotation.author= User.objects.get(pk=1)
    annotation.save()

You'll also need to import the models

    from annotations.models import Annotation 
    from django.contrib.auth.models import User

Running the tests gives:

    privacy= models.PositiveSmallIntegerField(choices=PRIVACY_OPTIONS, default=PRIVACY_OPTIONS['private'])
    TypeError: tuple indices must be integers, not str

Hmm, yeah, let's see what went wrong. The default value is supposed to have an integer value, which we thought we gave when we said PRIVACY_OPTIONS['private']. But, tuple indices must be integers, not str. So, either we make a dictionary out of it, but that would give an error (unhashable type), or we refine our PRIVACY_OPTIONS as

    PRIVACY_PUBLIC = 0
    PRIVACY_AUTHOR = 1
    PRIVACY_GROUP = 2
    PRIVACY_PRIVATE = 3
    PRIVACY_OPTIONS = (
    (PRIVACY_PUBLIC, 'public'),
    (PRIVACY_AUTHOR, 'author'),
    (PRIVACY_GROUP, 'group'),
    (PRIVACY_PRIVATE, 'private'),
    )

And our privacy is now set as:

    privacy= models.PositiveSmallIntegerField(choices=PRIVACY_OPTIONS, default=PRIVACY_PRIVATE)

What is happening here? We are telling that privacy is a choices field which can take options specified by the tuple PRIVACY_OPTIONS which in itself contains a 'Value', 'Descriptive Text' pair as tuple. If nothing is provided, the value must be PRIVACY_PRIVATE which is set to an integer 3.

Now, it looks like:

    class Annotation(models.Model):
        PRIVACY_PUBLIC = 0
        PRIVACY_AUTHOR = 1
        PRIVACY_GROUP = 2
        PRIVACY_PRIVATE = 3
        PRIVACY_OPTIONS = (
        (PRIVACY_PUBLIC, 'public'),
        (PRIVACY_AUTHOR, 'author'),
        (PRIVACY_GROUP, 'group'),
        (PRIVACY_PRIVATE, 'private'),
        )
        #Relations with other objects
        content_type = models.ForeignKey(ContentType, 
        verbose_name=_("Content Type"), 
        related_name="content_type_set_for_annotations")
        object_id = models.TextField(_("object ID"))
        content_object = generic.GenericForeignKey(ct_field="content_type", fk_field="object_id")
        #User relevant stuff
        body = models.TextField()
        '''
        Annotations can be written only by logged in users. If the user cannot afford to make himself and his interest in reading known, alas, we cannot help him in case of making annotations. It is also to prevent hit and run comments by people under anonymity.
        '''
        author = models.ForeignKey(User, related_name="author", null=False, blank=False, verbose_name=_("Annotation author"))
        #Privacy settings
        privacy= models.PositiveSmallIntegerField(choices=PRIVACY_OPTIONS, default=PRIVACY_PRIVATE)
        #Privacy reset for Spam protection, if annotation has been shared (and marked as offensive)
        privacy_override = models.BooleanField(default=False)
        #Shared with these users.
        shared_with = models.ManyToManyField(User, through="Annotation_share_map", null="True")
        #Statistics related stuff
        date_created = models.DateTimeField(auto_now_add=True)
        date_modified = models.DateTimeField(auto_now=True)  

Rerunning the test gives this error now:

    CommandError: One or more models did not validate:
    annotations.annotation: 'shared_with' specifies an m2m relation through model Annotation_share_map, which has not been installed

Some progress, see? We know this one, we did not create a share map. But before we did that, do we know what it is, and why do we need it?

This field is relevant when the user wants to share the annotation with a select few people who are also members of the portal (that is, have a user ID). It can just be the author of the post, if the privacy is set to PRIVACY_AUTHOR, or could be many people. Under ordinary conditions, if we did not give a 'through' parameter in our shared_with attribute, Django would have created a mapping table implicitly, with two columns: First would be the Primary Key of this instance (Annotations Table), and other would be the Primary Key of the Users with whom the annotation must be shared.

Well, that is all good, then why do we need to specify our own? Simple, for storing additional statistics! For example, we may want to notify the other member that an annotation has been shared with him (example, notify the author that an annotation has been made on his article). If the user modifies the annotation, we must not send a notification again (that would be a nuisance). So, while signals would do it each time, we don't want such kind of behaviour, and hence, would make it conditional based on a field inside this table.

So, let's make one:

class Annotation_share_map(models.Model):
    user = models.ForeignKey(User)
    annotation = models.ForeignKey(Annotation)
    notified_flag = models.BooleanField(default = False)
    
    class Meta:
        app_label = 'annotations' 

Rerunning the tests:

It says that the User constraint is missing. Aah yes! the test runner does not use our database, and also did not ask us for a user name. So, we need to create one:

    user = User(username="craft")
    user.save()

and

    annotation.author= User.objects.get(id=1)

Please note that this method of creation of user from a model is pretty stupid (though it served my purpose here). Use User.objects.create_user instead. Do explore Django unit testing framework, it has neat features which I was still learning when I was writing these tutorials.

Now, running the test passes the test. This simply tests our model. But what we would like to test is our code flow. The code flow goes something like:

  1. User writes an annotation.
  2. User presses 'Post' and the POST request is sent to the backend to the URL '/annotations/new'
  3. In the backend, the URL resolver routes the request to the Annotation App to its view.
  4. In the view, we first validate the parameters of the incoming request for their sanity.
  5. If the inputs are sane, we write them to the database and return a 200 Return code (OK), returning the newly created Annotation content.
  6. The user sees the Annotation that he created in the form as a new annotation.

Did we read form? Django can create forms from the models itself. They're modelforms. Additionally, Django forms come with features to check the sanity of the data passed. So, here is what we'll do:

  1. Use modelforms to create a form of our model.
  2. Feed our incoming data into the form class and validate it.
  3. If the validation is successful, we'll save it.

But for that, we'll need a form. So, lets do that.

It makes sense that we have some pre-created data, from fixtures. Blogging App provides us with some initial data. For my case, I have a username and password 'craft'. You mustn't do that (setting the same username and passwords in production. I did it because I forget passwords.)

So, here's our model form:

from annotations.models import Annotation
class AnnotationForm(forms.ModelForm):
    class Meta:
        model = Annotation
        fields = ['id', 'content_type', 'object_id', 'body', 'author', 'privacy',
        'paragraph','privacy_override', 'shared_with']

That done, let us see if we can actually post something. But before, let us plan what we are going to do.

Create a POST request and send it to our server. The server must route it to the annotations app, where we will check if the request type is POST. If it is, we pass the POST data into the Form Class and check if it validates. If it does, we save it. If it doesn't, we will do something about it. In any case, we expect that the content-type and object ID of parent post will be correct. We will get an instance of the parent object (without having to know what class it is) and call its get_absolute_url() method to know where we want to go back to as redirect. You see, we're as of yet not AJAXing our request. That's up next!

In any case, we'd be redirecting back to that page only, for now. So, in our test, we'll verify that we actually redirect to the right page. Here's the test:

def test_POST_annotation(self):
    #the result must redirect to the same page but not reload the same page (for now)
    response = self.client.post(
        '/annotations/',
        data = {
            'content_type': '9',
            'object_id':'1',
            'paragraph':'1',
            'body':'Dreaming is good, day dreaming, not so good.',
            'author':str(self.user.id),
            'privacy':'3',
            'privacy_override': '0',
            'shared_with':'',
            },
        )
    self.assertRedirects(response, '/blogging/articles/i-have-a-dream-by-martin-luther-king/1/')

And here's the view:

def home(request):
    if request.method == 'POST':
        #Handle the post request
        annotation_form = AnnotationForm(request.POST)
        #validate form
        if annotation_form.is_valid() is False:
            #Parse and debug error
            print 'Did not validate'
            print annotation_form.errors            
        else:            
            #save the annotation
            annotation_form.save()
        #Find the reverse URL of the object where this annotation was posted
        #Get an instance of the object on which the annotation was posted.
        content_type = annotation_form.cleaned_data['content_type'];
        object_id = annotation_form.cleaned_data['object_id'];
        #Get row from contentType which has content_type
        content_object = ContentType.objects.get_for_id(content_type.id)
        #Get row from parent table whose parameters are stored in the object we fetched
        #object_instance = content_object.get_object_for_this_type(pk=object_id)
        object_instance = content_object.model_class().objects.get(id=object_id) 
        #get a reverse URL now
        reverse_url = object_instance.get_absolute_url()      
        return(HttpResponseRedirect(reverse_url))
    elif request.method == 'GET':
        #Handle the GET request
        pass

Simple? (It is pretty commented even for a python routine and I like it this way so that I don't have to re-read everything from the tip of the iceberg till its bottom to make sense of the lines. I have a scarce memory resource.)

Well, our form validation fails for the many to many field. It says """ is not a valid value for a primary key." Though we wanted it as optional value, and having set the null=True and blank=True, we are not able to skip it. If we don't pass anything, Python raises "Cannot set values on a ManyToManyField which specifies an intermediary model. Use annotations.Annotation_share_map's Manager instead." That says that we are using a custom manager for our many to many tables share map.

Now, you can refer to this link or to Django modelForms documentation for a little primer

But, this helps:

    else:            
        #save the annotation
        #Can't save a M2M field when using a custom table using 'through'
        mapping = annotation_form.save(commit=False)
        mapping.save()
        for user in annotation_form.cleaned_data.get('shared_with'):
        sharing = Annotation_share_map(annotation=mapping, user=user)
        sharing.save()

Also, now we can test if it was actually saved. For now, let us just extend the current test only (though it is good practice to test only one thing in one test). We'll separate out the tests later.

    self.assertEqual(Annotation.objects.all().count(), 1)
    annotation = Annotation.objects.all()[0]
    self.assertEqual(annotation.body, 'Dreaming is good, day dreaming, not so good.')
    self.assertEqual(annotation.paragraph, 1)

Running the tests, all pass. Good going so far.

So far, we've just tested creation of annotations. Let us now try to fetch created annotations. But wait! we haven't catered to that function yet. We'll do that now. But let us first try to see what we're going to do (and people advice that you try to 'see' in a test). I've found that it takes a little time to get the hang of writing a test first. So, I'll stick to first writing it down on a page, then (if possible depending on my dexterity of writing one) write a test, and then code. If not, I'll be writing the test immediately after I've written the first few lines of code which can be tested. The idea is to take small, traceable steps at a time. The skill cannot be gained by just reading a book.

Here, since we've already written the code for posting an annotation, our test would look something like - creating annotations, and then, making a 'GET' request. The content of the annotations we just created must be present in the response. And we've already discussed the 'GET' scheme, the parameters we are going to pass in the request.

So, our test can look like:

def test_retrieve_annotations_for_post(self):
    #use test client to visit the page
    #create a few annotations first
    self.client.post(
        '/annotations/',
        data = {
            'content_type': '9',
            'object_id':'1',
            'paragraph':'1',
            'body':'Dreaming is good, day dreaming, not so good.',
            'author':str(self.user.id),
            'privacy':'3',
            'privacy_override': '0',
            'shared_with':'1',
            },
        )
    response = self.client.get('/annotations/?content_type=blogcontent&object_id=1')
    #get must return annotations in an HttpResponse object.
    self.assertContains(response, 'Dreaming is good, day dreaming, not so good.')

Now, we add the functionality in our view too:

    elif request.method == 'GET':
        #Handle the GET request
        content_type = ContentType.objects.get(model=request.GET.get('content_type', None))
        object_id = request.GET.get('object_id', None)
        annotation = Annotation.objects.filter(content_type=content_type.id, object_id=object_id)[0]
        return HttpResponse(annotation.body)

That is a little hacky. Firstly, I'm filtering on the ContentTypes table with just the model field, though under unknown circumstances, I would have to use a pair of model and app name, because more than one app may use the same model name. Here' I can say we are just lucky (Because we made it that way). Second, since it is in our wishlist that we don't want to do a conventional POST, but through Ajax, I have not made a template or a proper HTML rendered response, but just sent the body of the annotation I just made in an HttpResponse, so that my test passes. Had it been more than one annotation, this code would break. But not to worry, we'll fix that in the next tutorial, while using Ajax calls and response.

blog comments powered by Disqus