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.
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:
- User writes an annotation.
- User presses 'Post' and the
POST
request is sent to the backend to the URL '/annotations/new
' - In the backend, the URL resolver routes the request to the Annotation App to its view.
- In the view, we first validate the parameters of the incoming request for their sanity.
- If the inputs are sane, we write them to the database and return a
200
Return code (OK), returning the newly created Annotation content. - 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:
- Use
modelforms
to create a form of our model. - Feed our incoming data into the form class and validate it.
- 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.