Django Blog Tutorial - the Next Generation - Part 3

Published by at 3rd January 2014 12:57 pm

Hello again! In this instalment, we're going to do the following:

  • Add support for flat pages
  • Add support for multiple authors
  • Add a third-party comment system

Flat pages

Django ships with a number of useful apps - we've already used the admin interface. The flat pages app is another very handy app that comes with Django, and we'll use it to allow the blog author to create a handful of flat pages.

First of all, you'll need to install the flatpages app. Edit the INSTALLED_APPS setting as follows:

1INSTALLED_APPS = (
2 'django.contrib.admin',
3 'django.contrib.auth',
4 'django.contrib.contenttypes',
5 'django.contrib.sessions',
6 'django.contrib.messages',
7 'django.contrib.staticfiles',
8 'south',
9 'blogengine',
10 'django.contrib.sites',
11 'django.contrib.flatpages',
12)

Note that we needed to enable the sites framework as well. You'll also need to set the SITE_ID setting:

SITE_ID = 1

With that done, run python manage.py syncdb to create the required database tables. Now, let's use the sqlall command to take a look at the database structure generated for the flat pages:

1BEGIN;
2CREATE TABLE "django_flatpage_sites" (
3 "id" integer NOT NULL PRIMARY KEY,
4 "flatpage_id" integer NOT NULL,
5 "site_id" integer NOT NULL REFERENCES "django_site" ("id"),
6 UNIQUE ("flatpage_id", "site_id")
7)
8;
9CREATE TABLE "django_flatpage" (
10 "id" integer NOT NULL PRIMARY KEY,
11 "url" varchar(100) NOT NULL,
12 "title" varchar(200) NOT NULL,
13 "content" text NOT NULL,
14 "enable_comments" bool NOT NULL,
15 "template_name" varchar(70) NOT NULL,
16 "registration_required" bool NOT NULL
17)
18;
19CREATE INDEX "django_flatpage_sites_872c4601" ON "django_flatpage_sites" ("flatpage_id");
20CREATE INDEX "django_flatpage_sites_99732b5c" ON "django_flatpage_sites" ("site_id");
21CREATE INDEX "django_flatpage_c379dc61" ON "django_flatpage" ("url");
22
23COMMIT;

As mentioned previously, all models in Django have an id attribute by default. Each flat page also has a URL, title, and content.

Also note the separate django_flatpage_sites table, which maps sites to flat pages. Django can run multiple sites from the same web app, and so flat pages must be allocated to a specific site. This relationship is a many-to-many relationship, so one flat page can appear on more than one site.

The other fields are hidden by default in the admin and can be ignored. Let's have a go with Django's handy shell to explore the flatpage. Run python manage.py shell and you'll be able to interact with your Django application interactively:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py shell
2Python 2.7.6 (default, Nov 23 2013, 13:53:45)
3[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
4Type "help", "copyright", "credits" or "license" for more information.
5(InteractiveConsole)
6>>> from django.contrib.flatpages.models import *
7>>> FlatPage
8<class 'django.contrib.flatpages.models.FlatPage'>
9>>> from django.contrib.sites.models import Site
10>>> Site.objects.all()
11[<Site: example.com>]

As you can see, flatpages is a Django app similar to the blogengine one, with its own models, as is sites. You can see that the FlatPage class is a model. We can create an instance of it and save it interactively:

1>>> f = FlatPage()
2>>> f.url = '/about/'
3>>> f.title = 'About me'
4>>> f.content = 'All about me'
5>>> f.save()
6>>> f.sites.add(Site.objects.all()[0])
7>>> f.save()

Note that because the relationship between the site and the flat page is a many-to-many relationship, we need to save it first, then use the add method to add the site to the list of sites.

We can retrieve it:

1>>> FlatPage.objects.all()
2[<FlatPage: /about/ -- About me>]
3>>> FlatPage.objects.all()[0]
4<FlatPage: /about/ -- About me>
5>>> FlatPage.objects.all()[0].title
6u'About me'

This command is often handy for debugging problems with your models interactively. If you now run the server and visit the admin, you should notice that the Flatpages app is now visible there, and the 'About me' flat page is now shown in there.

Let's also take a look at the SQL required for the Site model. Run python manage.py sqlall sites:

1BEGIN;
2CREATE TABLE "django_site" (
3 "id" integer NOT NULL PRIMARY KEY,
4 "domain" varchar(100) NOT NULL,
5 "name" varchar(50) NOT NULL
6)
7;
8
9COMMIT;

Again, very simple - just a domain and a name.

So, now that we have a good idea of how the flat page system works, we can write a test for it. We don't need to write unit tests for the model because Django already does that, but we do need to write an acceptance test to ensure we can create flat pages and they will be where we expect them to be. Add the following to the top of the test file:

1from django.contrib.flatpages.models import FlatPage
2from django.contrib.sites.models import Site

Now, before we write this test, there's some duplication to resolve. We have two tests that subclass LiveServerTestCase, and both have the same method, setUp. We can save ourselves some hassle by creating a new class containing this method and having both these tests inherit from it. We'll do that now because the flat page test can also be based on it. Create the following class just after PostTest:

1class BaseAcceptanceTest(LiveServerTestCase):
2 def setUp(self):
3 self.client = Client()

Then remove the setUp method from each of the two tests based on LiveServerTestCase, and change their parent class to BaseAcceptanceTest:

1class AdminTest(BaseAcceptanceTest):
2
3class PostViewTest(BaseAcceptanceTest):

With that done, run the tests and they should pass. Commit your changes:

1$ git add blogengine/tests.py django_tutorial_blog_ng/settings.py
2$ git commit -m 'Added flatpages to installed apps'

Now we can get started in earnest on our test for the flat pages:

1class FlatPageViewTest(BaseAcceptanceTest):
2 def test_create_flat_page(self):
3 # Create flat page
4 page = FlatPage()
5 page.url = '/about/'
6 page.title = 'About me'
7 page.content = 'All about me'
8 page.save()
9
10 # Add the site
11 page.sites.add(Site.objects.all()[0])
12 page.save()
13
14 # Check new page saved
15 all_pages = FlatPage.objects.all()
16 self.assertEquals(len(all_pages), 1)
17 only_page = all_pages[0]
18 self.assertEquals(only_page, page)
19
20 # Check data correct
21 self.assertEquals(only_page.url, '/about/')
22 self.assertEquals(only_page.title, 'About me')
23 self.assertEquals(only_page.content, 'All about me')
24
25 # Get URL
26 page_url = only_page.get_absolute_url()
27
28 # Get the page
29 response = self.client.get(page_url)
30 self.assertEquals(response.status_code, 200)
31
32 # Check title and content in response
33 self.assertTrue('About me' in response.content)
34 self.assertTrue('All about me' in response.content)

Let's run our tests:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3......F..
4======================================================================
5FAIL: test_create_flat_page (blogengine.tests.FlatPageViewTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 272, in test_create_flat_page
9 self.assertEquals(response.status_code, 200)
10AssertionError: 404 != 200
11
12----------------------------------------------------------------------
13Ran 9 tests in 2.760s
14
15FAILED (failures=1)

We can see why it's failed - in our flat page test, the status code is 404, indicating the page was not found. This just means we haven't put flat page support into our URLconf. So let's fix that:

1from django.conf.urls import patterns, include, url
2
3from django.contrib import admin
4admin.autodiscover()
5
6urlpatterns = patterns('',
7 # Examples:
8 # url(r'^$', 'django_tutorial_blog_ng.views.home', name='home'),
9 # url(r'^blog/', include('blog.urls')),
10
11 url(r'^admin/', include(admin.site.urls)),
12
13 # Blog URLs
14 url(r'', include('blogengine.urls')),
15
16 # Flat pages
17 url(r'', include('django.contrib.flatpages.urls')),
18)

Let's run our tests again:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3......E..
4======================================================================
5ERROR: test_create_flat_page (blogengine.tests.FlatPageViewTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 276, in test_create_flat_page
9 response = self.client.get(page_url)
10 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 473, in get
11 response = super(Client, self).get(path, data=data, **extra)
12 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 280, in get
13 return self.request(**r)
14 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/test/client.py", line 444, in request
15 six.reraise(*exc_info)
16 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/core/handlers/base.py", line 114, in get_response
17 response = wrapped_callback(request, *callback_args, **callback_kwargs)
18 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/contrib/flatpages/views.py", line 45, in flatpage
19 return render_flatpage(request, f)
20 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/utils/decorators.py", line 99, in _wrapped_view
21 response = view_func(request, *args, **kwargs)
22 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/contrib/flatpages/views.py", line 60, in render_flatpage
23 t = loader.get_template(DEFAULT_TEMPLATE)
24 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/template/loader.py", line 138, in get_template
25 template, origin = find_template(template_name)
26 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/venv/lib/python2.7/site-packages/django/template/loader.py", line 131, in find_template
27 raise TemplateDoesNotExist(name)
28TemplateDoesNotExist: flatpages/default.html
29
30----------------------------------------------------------------------
31Ran 9 tests in 3.557s
32
33FAILED (errors=1)
34Destroying test database for alias 'default'...

Our test still fails, but we can easily see why - the template flatpages/default.html doesn't exist. So we create it:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 <div class="post">
7 <h1>{{ flatpage.title }}</h1>
8 {{ flatpage.content|custom_markdown }}
9 </div>
10
11 {% endblock %}

This template is based on the blog post one, and just changes a handful of variable names. Note that it can still inherit from the blogengine base template, and in this case we're using that for the sake of consistency.

If you run your tests, you should now see that they pass, so we'll commit our changes:

1$ git add templates/ django_tutorial_blog_ng/ blogengine/
2$ git commit -m 'Implemented flat page support'

Multiple authors

Next we'll add support for multiple authors. Now, Django already has a User model, and we'll leverage that to represent the authors. But first we'll write our test:

1from django.test import TestCase, LiveServerTestCase, Client
2from django.utils import timezone
3from blogengine.models import Post
4from django.contrib.flatpages.models import FlatPage
5from django.contrib.sites.models import Site
6from django.contrib.auth.models import User
7import markdown
8
9# Create your tests here.
10class PostTest(TestCase):
11 def test_create_post(self):
12 # Create the author
13 author = User.objects.create_user('testuser', 'user@example.com', 'password')
14 author.save()
15
16 # Create the post
17 post = Post()
18
19 # Set the attributes
20 post.title = 'My first post'
21 post.text = 'This is my first blog post'
22 post.slug = 'my-first-post'
23 post.pub_date = timezone.now()
24 post.author = author
25
26 # Save it
27 post.save()
28
29 # Check we can find it
30 all_posts = Post.objects.all()
31 self.assertEquals(len(all_posts), 1)
32 only_post = all_posts[0]
33 self.assertEquals(only_post, post)
34
35 # Check attributes
36 self.assertEquals(only_post.title, 'My first post')
37 self.assertEquals(only_post.text, 'This is my first blog post')
38 self.assertEquals(only_post.slug, 'my-first-post')
39 self.assertEquals(only_post.pub_date.day, post.pub_date.day)
40 self.assertEquals(only_post.pub_date.month, post.pub_date.month)
41 self.assertEquals(only_post.pub_date.year, post.pub_date.year)
42 self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
43 self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
44 self.assertEquals(only_post.pub_date.second, post.pub_date.second)
45 self.assertEquals(only_post.author.username, 'testuser')
46 self.assertEquals(only_post.author.email, 'user@example.com')
47
48class BaseAcceptanceTest(LiveServerTestCase):
49 def setUp(self):
50 self.client = Client()
51
52
53class AdminTest(BaseAcceptanceTest):
54 fixtures = ['users.json']
55
56 def test_login(self):
57 # Get login page
58 response = self.client.get('/admin/')
59
60 # Check response code
61 self.assertEquals(response.status_code, 200)
62
63 # Check 'Log in' in response
64 self.assertTrue('Log in' in response.content)
65
66 # Log the user in
67 self.client.login(username='bobsmith', password="password")
68
69 # Check response code
70 response = self.client.get('/admin/')
71 self.assertEquals(response.status_code, 200)
72
73 # Check 'Log out' in response
74 self.assertTrue('Log out' in response.content)
75
76 def test_logout(self):
77 # Log in
78 self.client.login(username='bobsmith', password="password")
79
80 # Check response code
81 response = self.client.get('/admin/')
82 self.assertEquals(response.status_code, 200)
83
84 # Check 'Log out' in response
85 self.assertTrue('Log out' in response.content)
86
87 # Log out
88 self.client.logout()
89
90 # Check response code
91 response = self.client.get('/admin/')
92 self.assertEquals(response.status_code, 200)
93
94 # Check 'Log in' in response
95 self.assertTrue('Log in' in response.content)
96
97 def test_create_post(self):
98 # Log in
99 self.client.login(username='bobsmith', password="password")
100
101 # Check response code
102 response = self.client.get('/admin/blogengine/post/add/')
103 self.assertEquals(response.status_code, 200)
104
105 # Create the new post
106 response = self.client.post('/admin/blogengine/post/add/', {
107 'title': 'My first post',
108 'text': 'This is my first post',
109 'pub_date_0': '2013-12-28',
110 'pub_date_1': '22:00:04',
111 'slug': 'my-first-post'
112 },
113 follow=True
114 )
115 self.assertEquals(response.status_code, 200)
116
117 # Check added successfully
118 self.assertTrue('added successfully' in response.content)
119
120 # Check new post now in database
121 all_posts = Post.objects.all()
122 self.assertEquals(len(all_posts), 1)
123
124 def test_edit_post(self):
125 # Create the author
126 author = User.objects.create_user('testuser', 'user@example.com', 'password')
127 author.save()
128
129 # Create the post
130 post = Post()
131 post.title = 'My first post'
132 post.text = 'This is my first blog post'
133 post.slug = 'my-first-post'
134 post.pub_date = timezone.now()
135 post.author = author
136 post.save()
137
138 # Log in
139 self.client.login(username='bobsmith', password="password")
140
141 # Edit the post
142 response = self.client.post('/admin/blogengine/post/1/', {
143 'title': 'My second post',
144 'text': 'This is my second blog post',
145 'pub_date_0': '2013-12-28',
146 'pub_date_1': '22:00:04',
147 'slug': 'my-second-post'
148 },
149 follow=True
150 )
151 self.assertEquals(response.status_code, 200)
152
153 # Check changed successfully
154 self.assertTrue('changed successfully' in response.content)
155
156 # Check post amended
157 all_posts = Post.objects.all()
158 self.assertEquals(len(all_posts), 1)
159 only_post = all_posts[0]
160 self.assertEquals(only_post.title, 'My second post')
161 self.assertEquals(only_post.text, 'This is my second blog post')
162
163 def test_delete_post(self):
164 # Create the author
165 author = User.objects.create_user('testuser', 'user@example.com', 'password')
166 author.save()
167
168 # Create the post
169 post = Post()
170 post.title = 'My first post'
171 post.text = 'This is my first blog post'
172 post.slug = 'my-first-post'
173 post.pub_date = timezone.now()
174 post.author = author
175 post.save()
176
177 # Check new post saved
178 all_posts = Post.objects.all()
179 self.assertEquals(len(all_posts), 1)
180
181 # Log in
182 self.client.login(username='bobsmith', password="password")
183
184 # Delete the post
185 response = self.client.post('/admin/blogengine/post/1/delete/', {
186 'post': 'yes'
187 }, follow=True)
188 self.assertEquals(response.status_code, 200)
189
190 # Check deleted successfully
191 self.assertTrue('deleted successfully' in response.content)
192
193 # Check post amended
194 all_posts = Post.objects.all()
195 self.assertEquals(len(all_posts), 0)
196
197class PostViewTest(BaseAcceptanceTest):
198 def test_index(self):
199 # Create the author
200 author = User.objects.create_user('testuser', 'user@example.com', 'password')
201 author.save()
202
203 # Create the post
204 post = Post()
205 post.title = 'My first post'
206 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
207 post.slug = 'my-first-post'
208 post.pub_date = timezone.now()
209 post.author = author
210 post.save()
211
212 # Check new post saved
213 all_posts = Post.objects.all()
214 self.assertEquals(len(all_posts), 1)
215
216 # Fetch the index
217 response = self.client.get('/')
218 self.assertEquals(response.status_code, 200)
219
220 # Check the post title is in the response
221 self.assertTrue(post.title in response.content)
222
223 # Check the post text is in the response
224 self.assertTrue(markdown.markdown(post.text) in response.content)
225
226 # Check the post date is in the response
227 self.assertTrue(str(post.pub_date.year) in response.content)
228 self.assertTrue(post.pub_date.strftime('%b') in response.content)
229 self.assertTrue(str(post.pub_date.day) in response.content)
230
231 # Check the link is marked up properly
232 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
233
234 def test_post_page(self):
235 # Create the author
236 author = User.objects.create_user('testuser', 'user@example.com', 'password')
237 author.save()
238
239 # Create the post
240 post = Post()
241 post.title = 'My first post'
242 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
243 post.slug = 'my-first-post'
244 post.pub_date = timezone.now()
245 post.author = author
246 post.save()
247
248 # Check new post saved
249 all_posts = Post.objects.all()
250 self.assertEquals(len(all_posts), 1)
251 only_post = all_posts[0]
252 self.assertEquals(only_post, post)
253
254 # Get the post URL
255 post_url = only_post.get_absolute_url()
256
257 # Fetch the post
258 response = self.client.get(post_url)
259 self.assertEquals(response.status_code, 200)
260
261 # Check the post title is in the response
262 self.assertTrue(post.title in response.content)
263
264 # Check the post text is in the response
265 self.assertTrue(markdown.markdown(post.text) in response.content)
266
267 # Check the post date is in the response
268 self.assertTrue(str(post.pub_date.year) in response.content)
269 self.assertTrue(post.pub_date.strftime('%b') in response.content)
270 self.assertTrue(str(post.pub_date.day) in response.content)
271
272 # Check the link is marked up properly
273 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
274
275
276class FlatPageViewTest(BaseAcceptanceTest):
277 def test_create_flat_page(self):
278 # Create flat page
279 page = FlatPage()
280 page.url = '/about/'
281 page.title = 'About me'
282 page.content = 'All about me'
283 page.save()
284
285 # Add the site
286 page.sites.add(Site.objects.all()[0])
287 page.save()
288
289 # Check new page saved
290 all_pages = FlatPage.objects.all()
291 self.assertEquals(len(all_pages), 1)
292 only_page = all_pages[0]
293 self.assertEquals(only_page, page)
294
295 # Check data correct
296 self.assertEquals(only_page.url, '/about/')
297 self.assertEquals(only_page.title, 'About me')
298 self.assertEquals(only_page.content, 'All about me')
299
300 # Get URL
301 page_url = str(only_page.get_absolute_url())
302
303 # Get the page
304 response = self.client.get(page_url)
305 self.assertEquals(response.status_code, 200)
306
307 # Check title and content in response
308 self.assertTrue('About me' in response.content)
309 self.assertTrue('All about me' in response.content)

Here we create a User object to represent the author. Note the create_user convenience method for creating new users quickly and easily.

We're going to exclude the author field from the admin - instead it's going to be automatically populated based on the session data, so that when a user creates a post they are automatically set as the author. We therefore don't need to make any changes for the acceptance tests for posts - our changes to the unit tests for the Post model are sufficient.

Run the tests, and they should fail:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3E........
4======================================================================
5ERROR: test_create_post (blogengine.tests.PostTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 45, in test_create_post
9 self.assertEquals(only_post.author.username, 'testuser')
10AttributeError: 'Post' object has no attribute 'author'
11
12----------------------------------------------------------------------
13Ran 9 tests in 3.620s
14
15FAILED (errors=1)
16Destroying test database for alias 'default'...

Let's add the missing author attribute:

1from django.db import models
2from django.contrib.auth.models import User
3
4# Create your models here.
5class Post(models.Model):
6 title = models.CharField(max_length=200)
7 pub_date = models.DateTimeField()
8 text = models.TextField()
9 slug = models.SlugField(max_length=40, unique=True)
10 author = models.ForeignKey(User)
11
12 def get_absolute_url(self):
13 return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
14
15 def __unicode__(self):
16 return self.title
17
18 class Meta:
19 ordering = ["-pub_date"]

Next, create the migrations:

$ python manage.py schemamigration --auto blogengine

You'll be prompted to either quit or provide a default author ID - select option 2 to provide the ID, then enter 1, which should be your own user account ID. Then run the migrations:

$ python manage.py migrate

Let's run our tests again:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3.F.F.....
4======================================================================
5FAIL: test_create_post (blogengine.tests.AdminTest)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 118, in test_create_post
9 self.assertTrue('added successfully' in response.content)
10AssertionError: False is not true
11
12======================================================================
13FAIL: test_edit_post (blogengine.tests.AdminTest)
14----------------------------------------------------------------------
15Traceback (most recent call last):
16 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 154, in test_edit_post
17 self.assertTrue('changed successfully' in response.content)
18AssertionError: False is not true
19
20----------------------------------------------------------------------
21Ran 9 tests in 3.390s
22
23FAILED (failures=2)
24Destroying test database for alias 'default'...

Our test still fails because the author field isn't set automatically. So we'll amend the admin to automatically set the author when the Post object is saved:

1import models
2from django.contrib import admin
3from django.contrib.auth.models import User
4
5class PostAdmin(admin.ModelAdmin):
6 prepopulated_fields = {"slug": ("title",)}
7 exclude = ('author',)
8
9 def save_model(self, request, obj, form, change):
10 obj.author = request.user
11 obj.save()
12
13admin.site.register(models.Post, PostAdmin)

This tells the admin to exclude the author field from any form for a post, and when the model is saved, to set the author to the user making the HTTP request. Now run the tests, and they should pass:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3.........
4----------------------------------------------------------------------
5Ran 9 tests in 4.086s
6
7OK
8Destroying test database for alias 'default'...

Time to commit again:

1$ git add blogengine/
2$ git commit -m 'Added author field'

Comments

The previous version of this tutorial implemented comments using Django's own comment system. However, this has since been deprecated from Django and turned into a separate project. So we have two options for how to implement comments:

Now, if you want to use the Django comment system, you can do so, and it shouldn't be too hard to puzzle out how to implement it using the documentation and my prior post. However, in my humble opinion, using a third-party comment system is the way to go for blog comments - they make it extremely easy for people to log in with multiple services without you having to write lots of additional code. They also make it significantly easier to moderate comments, and they're generally pretty good at handling comment spam.

Some of the available providers include:

For demonstration purposes, we'll use Facebook comments, but this shouldn't require much work to adapt it to the other providers.

First of all, we need to include the Facebook JavaScript SDK:

1 <!-- Add your site or application content here -->
2
3 <div id="fb-root"></div>
4 <script>(function(d, s, id) {
5 var js, fjs = d.getElementsByTagName(s)[0];
6 if (d.getElementById(id)) return;
7 js = d.createElement(s); js.id = id;
8 js.src = "//connect.facebook.net/en_GB/all.js#xfbml=1";
9 fjs.parentNode.insertBefore(js, fjs);
10 }(document, 'script', 'facebook-jssdk'));</script>
11
12 <div class="navbar navbar-static-top navbar-inverse">
13 <div class="navbar-inner">
14 <div class="container">
15 <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
16 <span class="icon-bar"></span>
17 <span class="icon-bar"></span>
18 <span class="icon-bar"></span>
19 </a>
20 <a class="brand" href="/">My Django Blog</a>
21 <div class="nav-collapse collapse">
22 </div>
23 </div>
24 </div>
25 </div>

Now, the Facebook comment system requires that you pass through the absolute page URL when initialising the comments. At present we can't do that without hard-coding the domain name in our template, which we want to avoid. So, we need to add a site field to each post to identify the site it's associated with.

As usual, we update our tests first:

1from django.test import TestCase, LiveServerTestCase, Client
2from django.utils import timezone
3from blogengine.models import Post
4from django.contrib.flatpages.models import FlatPage
5from django.contrib.sites.models import Site
6from django.contrib.auth.models import User
7import markdown
8
9# Create your tests here.
10class PostTest(TestCase):
11 def test_create_post(self):
12 # Create the author
13 author = User.objects.create_user('testuser', 'user@example.com', 'password')
14 author.save()
15
16 # Create the site
17 site = Site()
18 site.name = 'example.com'
19 site.domain = 'example.com'
20 site.save()
21
22 # Create the post
23 post = Post()
24
25 # Set the attributes
26 post.title = 'My first post'
27 post.text = 'This is my first blog post'
28 post.slug = 'my-first-post'
29 post.pub_date = timezone.now()
30 post.author = author
31 post.site = site
32
33 # Save it
34 post.save()
35
36 # Check we can find it
37 all_posts = Post.objects.all()
38 self.assertEquals(len(all_posts), 1)
39 only_post = all_posts[0]
40 self.assertEquals(only_post, post)
41
42 # Check attributes
43 self.assertEquals(only_post.title, 'My first post')
44 self.assertEquals(only_post.text, 'This is my first blog post')
45 self.assertEquals(only_post.slug, 'my-first-post')
46 self.assertEquals(only_post.site.name, 'example.com')
47 self.assertEquals(only_post.site.domain, 'example.com')
48 self.assertEquals(only_post.pub_date.day, post.pub_date.day)
49 self.assertEquals(only_post.pub_date.month, post.pub_date.month)
50 self.assertEquals(only_post.pub_date.year, post.pub_date.year)
51 self.assertEquals(only_post.pub_date.hour, post.pub_date.hour)
52 self.assertEquals(only_post.pub_date.minute, post.pub_date.minute)
53 self.assertEquals(only_post.pub_date.second, post.pub_date.second)
54 self.assertEquals(only_post.author.username, 'testuser')
55 self.assertEquals(only_post.author.email, 'user@example.com')
56
57class BaseAcceptanceTest(LiveServerTestCase):
58 def setUp(self):
59 self.client = Client()
60
61
62class AdminTest(BaseAcceptanceTest):
63 fixtures = ['users.json']
64
65 def test_login(self):
66 # Get login page
67 response = self.client.get('/admin/')
68
69 # Check response code
70 self.assertEquals(response.status_code, 200)
71
72 # Check 'Log in' in response
73 self.assertTrue('Log in' in response.content)
74
75 # Log the user in
76 self.client.login(username='bobsmith', password="password")
77
78 # Check response code
79 response = self.client.get('/admin/')
80 self.assertEquals(response.status_code, 200)
81
82 # Check 'Log out' in response
83 self.assertTrue('Log out' in response.content)
84
85 def test_logout(self):
86 # Log in
87 self.client.login(username='bobsmith', password="password")
88
89 # Check response code
90 response = self.client.get('/admin/')
91 self.assertEquals(response.status_code, 200)
92
93 # Check 'Log out' in response
94 self.assertTrue('Log out' in response.content)
95
96 # Log out
97 self.client.logout()
98
99 # Check response code
100 response = self.client.get('/admin/')
101 self.assertEquals(response.status_code, 200)
102
103 # Check 'Log in' in response
104 self.assertTrue('Log in' in response.content)
105
106 def test_create_post(self):
107 # Log in
108 self.client.login(username='bobsmith', password="password")
109
110 # Check response code
111 response = self.client.get('/admin/blogengine/post/add/')
112 self.assertEquals(response.status_code, 200)
113
114 # Create the new post
115 response = self.client.post('/admin/blogengine/post/add/', {
116 'title': 'My first post',
117 'text': 'This is my first post',
118 'pub_date_0': '2013-12-28',
119 'pub_date_1': '22:00:04',
120 'slug': 'my-first-post',
121 'site': '1'
122 },
123 follow=True
124 )
125 self.assertEquals(response.status_code, 200)
126
127 # Check added successfully
128 self.assertTrue('added successfully' in response.content)
129
130 # Check new post now in database
131 all_posts = Post.objects.all()
132 self.assertEquals(len(all_posts), 1)
133
134 def test_edit_post(self):
135 # Create the author
136 author = User.objects.create_user('testuser', 'user@example.com', 'password')
137 author.save()
138
139 # Create the site
140 site = Site()
141 site.name = 'example.com'
142 site.domain = 'example.com'
143 site.save()
144
145 # Create the post
146 post = Post()
147 post.title = 'My first post'
148 post.text = 'This is my first blog post'
149 post.slug = 'my-first-post'
150 post.pub_date = timezone.now()
151 post.author = author
152 post.site = site
153 post.save()
154
155 # Log in
156 self.client.login(username='bobsmith', password="password")
157
158 # Edit the post
159 response = self.client.post('/admin/blogengine/post/1/', {
160 'title': 'My second post',
161 'text': 'This is my second blog post',
162 'pub_date_0': '2013-12-28',
163 'pub_date_1': '22:00:04',
164 'slug': 'my-second-post',
165 'site': '1'
166 },
167 follow=True
168 )
169 self.assertEquals(response.status_code, 200)
170
171 # Check changed successfully
172 self.assertTrue('changed successfully' in response.content)
173
174 # Check post amended
175 all_posts = Post.objects.all()
176 self.assertEquals(len(all_posts), 1)
177 only_post = all_posts[0]
178 self.assertEquals(only_post.title, 'My second post')
179 self.assertEquals(only_post.text, 'This is my second blog post')
180
181 def test_delete_post(self):
182 # Create the author
183 author = User.objects.create_user('testuser', 'user@example.com', 'password')
184 author.save()
185
186 # Create the site
187 site = Site()
188 site.name = 'example.com'
189 site.domain = 'example.com'
190 site.save()
191
192 # Create the post
193 post = Post()
194 post.title = 'My first post'
195 post.text = 'This is my first blog post'
196 post.slug = 'my-first-post'
197 post.pub_date = timezone.now()
198 post.site = site
199 post.author = author
200 post.save()
201
202 # Check new post saved
203 all_posts = Post.objects.all()
204 self.assertEquals(len(all_posts), 1)
205
206 # Log in
207 self.client.login(username='bobsmith', password="password")
208
209 # Delete the post
210 response = self.client.post('/admin/blogengine/post/1/delete/', {
211 'post': 'yes'
212 }, follow=True)
213 self.assertEquals(response.status_code, 200)
214
215 # Check deleted successfully
216 self.assertTrue('deleted successfully' in response.content)
217
218 # Check post amended
219 all_posts = Post.objects.all()
220 self.assertEquals(len(all_posts), 0)
221
222class PostViewTest(BaseAcceptanceTest):
223 def test_index(self):
224 # Create the author
225 author = User.objects.create_user('testuser', 'user@example.com', 'password')
226 author.save()
227
228 # Create the site
229 site = Site()
230 site.name = 'example.com'
231 site.domain = 'example.com'
232 site.save()
233
234 # Create the post
235 post = Post()
236 post.title = 'My first post'
237 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
238 post.slug = 'my-first-post'
239 post.pub_date = timezone.now()
240 post.author = author
241 post.site = site
242 post.save()
243
244 # Check new post saved
245 all_posts = Post.objects.all()
246 self.assertEquals(len(all_posts), 1)
247
248 # Fetch the index
249 response = self.client.get('/')
250 self.assertEquals(response.status_code, 200)
251
252 # Check the post title is in the response
253 self.assertTrue(post.title in response.content)
254
255 # Check the post text is in the response
256 self.assertTrue(markdown.markdown(post.text) in response.content)
257
258 # Check the post date is in the response
259 self.assertTrue(str(post.pub_date.year) in response.content)
260 self.assertTrue(post.pub_date.strftime('%b') in response.content)
261 self.assertTrue(str(post.pub_date.day) in response.content)
262
263 # Check the link is marked up properly
264 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
265
266 def test_post_page(self):
267 # Create the author
268 author = User.objects.create_user('testuser', 'user@example.com', 'password')
269 author.save()
270
271 # Create the site
272 site = Site()
273 site.name = 'example.com'
274 site.domain = 'example.com'
275 site.save()
276
277 # Create the post
278 post = Post()
279 post.title = 'My first post'
280 post.text = 'This is [my first blog post](http://127.0.0.1:8000/)'
281 post.slug = 'my-first-post'
282 post.pub_date = timezone.now()
283 post.author = author
284 post.site = site
285 post.save()
286
287 # Check new post saved
288 all_posts = Post.objects.all()
289 self.assertEquals(len(all_posts), 1)
290 only_post = all_posts[0]
291 self.assertEquals(only_post, post)
292
293 # Get the post URL
294 post_url = only_post.get_absolute_url()
295
296 # Fetch the post
297 response = self.client.get(post_url)
298 self.assertEquals(response.status_code, 200)
299
300 # Check the post title is in the response
301 self.assertTrue(post.title in response.content)
302
303 # Check the post text is in the response
304 self.assertTrue(markdown.markdown(post.text) in response.content)
305
306 # Check the post date is in the response
307 self.assertTrue(str(post.pub_date.year) in response.content)
308 self.assertTrue(post.pub_date.strftime('%b') in response.content)
309 self.assertTrue(str(post.pub_date.day) in response.content)
310
311 # Check the link is marked up properly
312 self.assertTrue('<a href="http://127.0.0.1:8000/">my first blog post</a>' in response.content)
313
314
315class FlatPageViewTest(BaseAcceptanceTest):
316 def test_create_flat_page(self):
317 # Create flat page
318 page = FlatPage()
319 page.url = '/about/'
320 page.title = 'About me'
321 page.content = 'All about me'
322 page.save()
323
324 # Add the site
325 page.sites.add(Site.objects.all()[0])
326 page.save()
327
328 # Check new page saved
329 all_pages = FlatPage.objects.all()
330 self.assertEquals(len(all_pages), 1)
331 only_page = all_pages[0]
332 self.assertEquals(only_page, page)
333
334 # Check data correct
335 self.assertEquals(only_page.url, '/about/')
336 self.assertEquals(only_page.title, 'About me')
337 self.assertEquals(only_page.content, 'All about me')
338
339 # Get URL
340 page_url = str(only_page.get_absolute_url())
341
342 # Get the page
343 response = self.client.get(page_url)
344 self.assertEquals(response.status_code, 200)
345
346 # Check title and content in response
347 self.assertTrue('About me' in response.content)
348 self.assertTrue('All about me' in response.content)

All we've done here is to add the site attribute when creating a new post using the Django database API, and when we create one via the admin, we add an additional site parameter to the HTTP POST request with a value of 1. Run the tests and they should fail:

1Creating test database for alias 'default'...
2E........
3======================================================================
4ERROR: test_create_post (blogengine.tests.PostTest)
5----------------------------------------------------------------------
6Traceback (most recent call last):
7 File "/Users/matthewdaly/Projects/django_tutorial_blog_ng/blogengine/tests.py", line 46, in test_create_post
8 self.assertEquals(only_post.site.name, 'example.com')
9AttributeError: 'Post' object has no attribute 'site'
10
11----------------------------------------------------------------------
12Ran 9 tests in 4.313s
13
14FAILED (errors=1)
15Destroying test database for alias 'default'...

So we need to add the site attribute to the Post model. Let's do that:

1from django.db import models
2from django.contrib.auth.models import User
3from django.contrib.sites.models import Site
4
5# Create your models here.
6class Post(models.Model):
7 title = models.CharField(max_length=200)
8 pub_date = models.DateTimeField()
9 text = models.TextField()
10 slug = models.SlugField(max_length=40, unique=True)
11 author = models.ForeignKey(User)
12 site = models.ForeignKey(Site)
13
14 def get_absolute_url(self):
15 return "/%s/%s/%s/" % (self.pub_date.year, self.pub_date.month, self.slug)
16
17 def __unicode__(self):
18 return self.title
19
20 class Meta:
21 ordering = ["-pub_date"]

Now create and run the migrations - you'll be prompted to create a default value for the site attribute as well:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py schemamigration --auto blogengine
2 ? The field 'Post.site' does not have a default specified, yet is NOT NULL.
3 ? Since you are adding this field, you MUST specify a default
4 ? value to use for existing rows. Would you like to:
5 ? 1. Quit now, and add a default to the field in models.py
6 ? 2. Specify a one-off value to use for existing columns now
7 ? Please select a choice: 2
8 ? Please enter Python code for your one-off default value.
9 ? The datetime module is available, so you can do e.g. datetime.date.today()
10 >>> 1
11 + Added field site on blogengine.Post
12Created 0005_auto__add_field_post_site.py. You can now apply this migration with: ./manage.py migrate blogengine
13(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py migrate
14Running migrations for blogengine:
15 - Migrating forwards to 0005_auto__add_field_post_site.
16 > blogengine:0005_auto__add_field_post_site
17 - Loading initial data for blogengine.
18Installed 0 object(s) from 0 fixture(s)

Our tests should then pass:

1(venv)Smith:django_tutorial_blog_ng matthewdaly$ python manage.py test
2Creating test database for alias 'default'...
3.........
4----------------------------------------------------------------------
5Ran 9 tests in 4.261s
6
7OK
8Destroying test database for alias 'default'...

Now we can include our full page URL on the post detail page:

1{% extends "blogengine/includes/base.html" %}
2
3 {% load custom_markdown %}
4
5 {% block content %}
6 <div class="post">
7 <h1>{{ object.title }}</h1>
8 <h3>{{ object.pub_date }}</h3>
9 {{ object.text|custom_markdown }}
10
11 <h4>Comments</h4>
12 <div class="fb-comments" data-href="http://{{ post.site }}{{ post.get_absolute_url }}" data-width="470" data-num-posts="10"></div>
13
14 </div>
15
16 {% endblock %}

If you want to customise the comments, take a look at the documentation for Facebook Comments.

With that done, we can commit our changes:

1$ git add blogengine/ templates/
2$ git commit -m 'Implemented Facebook comments'

And that wraps up this lesson. As usual, you can easily switch to today's lesson with git checkout lesson-3. Next time we'll implement categories and tags, and create an RSS feed for our blog posts.