A little Hacker News in Django (part 3)

Daniel Dương
7 min readJun 5, 2018

--

We have some data in our database, I think it’s time to make our first view, the PostListView. Since it’s a ListView, we’ll use a class-based view. Actually, most of the time, you can use a class-based view, only in some tricky situations I would use a function. Now, if you don’t know what class to inherit from, there is a cool website: CCBV.

As said earlier, we’ll use a ListView. Let’s add our view in hnews/posts/views.py.

from django.views.generic import ListView


class PostListView(ListView):
pass

Let’s add the view in the routes. First, let’s create hnews/posts/urls.py .

touch hnews/posts/urls.py

Let’s include this file in hnews/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path('admin/', admin.site.urls),
path('posts/', include('hnews.posts.urls'))
]

Now with path , no need to use regular expressions! The namespace will be set on hnews/posts/urls.py. The file looks like this:

from django.urls import path

from . import views

app_name = 'posts'
urlpatterns = [
path('', views.PostListView.as_view(), name='list'),
]

Let’s start our dev server:

./manage.py runserver

Now if we visit http://localhost:8000/posts/, we’ll have some error. But at least, we know the route is good.

Let’s create a folder for the template for the view.

# daniel @ Evans in ~/Code/hnews on git:master o [13:57:36]
$ mkdir -p templates/posts/

And let’s add an empty template.

# daniel @ Evans in ~/Code/hnews on git:master o [13:58:25]
$ touch templates/posts/list.html

Let’s write a minimal template.

$ cat templates/posts/list.html
<html>
<body>
{% if posts %}
<ul>
{% for post in posts %}
<li>{{ post.title }}</li>
{% endfor %}
</ul>
{% endif %}
</body>
</html>

Now we have to modify our view.

from django.views.generic import ListView

from .models import Post


class PostListView(ListView):
template_name = 'posts/list.html'
model = Post
context_object_name = 'posts'

If you try to go to http://localhost:8000/posts/, you’ll have an error complaining about the template. We need to change our settings and add templates in TEMPLATES['DIRS'] .

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

Now, if you reload the page, it should work. Now, let’s commit, push the code to production and prepare some champagne… Just kidding, there is some work left.

Let’s do something funny, the X minutes ago feature. We want to write a method that returns how long ago a post was published. We’ll put the method in the Post class (fat model). Naming things is always hard, I could call it pretty_publication_date or something like that. But I’ll just call it how_long_ago. Now I just realized that creation_date wasn’t really good, publication_date would have been better.

In an enterprise setting, I would usually write more docstrings, but since I’m the only person working, I’ll just skip it. (Yeah… I know, it’s bad)

Now, before writing how_long_ago, we have to ask ourselves some questions (as usual). It’s a non trivial method, so I will write unit tests. It’s almost a pure function. The only side effect it has is that we have to call datetime.now(). Over the years, I had a lot of philosophical debates with myself on how to handle this.

Should I write a helper that is a pure function, that takes now as an argument?

def _how_long_ago(self, now):
...
def how_long_ago(self):
return self._how_long_ago(datetime.now())

Should I use the default argument trick?

def how_long_ago(self, _now=datetime.now):
now = _now()
...

or

def how_long_ago(self, now=None):
now = now or datetime.now()
...

Should I say screw that, and mock?

If you spend enough time on the internet, you’ll read so many solutions. With time, I just ignore all the noise and do what I think is OK. Today, it’s to mock.

from datetime import datetime, timedelta

from django.db import models
from django.contrib.auth.models import User
from django.template.defaultfilters import pluralize


class Post(models.Model):
creator = models.ForeignKey(
User,
related_name='posts',
on_delete=models.SET_NULL,
null=True,
)
creation_date = models.DateTimeField(auto_now_add=True)
url = models.URLField()
upvotes = models.ManyToManyField(User, through='PostUpvote')
title = models.CharField(max_length=256)

def how_long_ago(self):
how_long = datetime.now() - self.creation_date
if how_long < timedelta(minutes=1):
return f'{how_long.seconds} second{pluralize(how_long.seconds)} ago'
elif how_long < timedelta(hours=1):
return f'{how_long.minutes} minute{pluralize(how_long.minutes)} ago'
elif how_long < timedelta(days=1):
return f'{how_long.hours} minute{pluralize(how_long.hours)} ago'
else:
return f'{how_long.days} day{pluralize(how_long.days)} ago'

If you didn’t know, pluralize is used to put an s when necessary.

Now we have to write the tests. Usually I use something like pytest but I don’t want to install too many dependencies. Usually I also write my tests in tests/. But to make things easier, I’ll just write them in hnews/posts/tests.py.

To mock, I always get confused by the API. The documentation is here. I usually try stuff until it works, which means that I never remember how to do it.

from datetime import datetime, timedelta
from unittest import mock

from django.test import TestCase

from .models import Post


class PostTestCase(TestCase):
def test_how_long_0_seconds(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation
self.assertEqual(post.how_long_ago(), '0 seconds ago')

Now that we did it with 0 seconds, we need to do every tests…

So it turns out that seconds passed, but minutes didn’t.

from datetime import datetime, timedelta
from unittest import mock

from django.test import TestCase

from .models import Post


class PostTestCase(TestCase):
def test_how_long_0_seconds(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation
self.assertEqual(post.how_long_ago(), '0 seconds ago')

def test_how_long_1_second(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(seconds=1)
self.assertEqual(post.how_long_ago(), '1 second ago')

def test_how_long_multiple_seconds(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(seconds=42)
self.assertEqual(post.how_long_ago(), '42 seconds ago')

def test_how_long_1_minute(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(minutes=1)
self.assertEqual(post.how_long_ago(), '1 minute ago')

def test_how_long_multiple_minutes(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(minutes=42)
self.assertEqual(post.how_long_ago(), '42 minutes ago')

I had this error:

AttributeError: 'datetime.timedelta' object has no attribute 'minutes'

Looking at the timedelta documentation, there is this:

Only days, seconds and microseconds are stored internally. Arguments are converted to those units:

We need to fix our code.

from datetime import datetime, timedelta

from django.db import models
from django.contrib.auth.models import User
from django.template.defaultfilters import pluralize


class Post(models.Model):
creator = models.ForeignKey(
User,
related_name='posts',
on_delete=models.SET_NULL,
null=True,
)
creation_date = models.DateTimeField(auto_now_add=True)
url = models.URLField()
upvotes = models.ManyToManyField(User, through='PostUpvote')
title = models.CharField(max_length=256)

def how_long_ago(self):
how_long = datetime.now() - self.creation_date
if how_long < timedelta(minutes=1):
return f'{how_long.seconds} second{pluralize(how_long.seconds)} ago'
elif how_long < timedelta(hours=1):
# total_seconds returns a float
minutes = int(how_long.total_seconds()) // 60
return f'{minutes} minute{pluralize(minutes)} ago'
elif how_long < timedelta(days=1):
hours = int(how_long.total_seconds()) // 3600
return f'{hours} hour{pluralize(hours)} ago'
else:
return f'{how_long.days} day{pluralize(how_long.days)} ago'

See, tests do catch bugs! Now let’s check the hours and the days.

from datetime import datetime, timedelta
from unittest import mock

from django.test import TestCase

from .models import Post


class PostTestCase(TestCase):
def test_how_long_0_seconds(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation
self.assertEqual(post.how_long_ago(), '0 seconds ago')

def test_how_long_1_second(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(seconds=1)
self.assertEqual(post.how_long_ago(), '1 second ago')

def test_how_long_multiple_seconds(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(seconds=42)
self.assertEqual(post.how_long_ago(), '42 seconds ago')

def test_how_long_1_minute(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(minutes=1)
self.assertEqual(post.how_long_ago(), '1 minute ago')

def test_how_long_multiple_minutes(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(minutes=42)
self.assertEqual(post.how_long_ago(), '42 minutes ago')

def test_how_long_1_hour(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(hours=1)
self.assertEqual(post.how_long_ago(), '1 hour ago')

def test_how_long_multiple_hours(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(hours=20)
self.assertEqual(post.how_long_ago(), '20 hours ago')

def test_how_long_1_day(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(days=1)
self.assertEqual(post.how_long_ago(), '1 day ago')

def test_how_long_multiple_days(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(days=20)
self.assertEqual(post.how_long_ago(), '20 days ago')

Can we be more paranoid? I know I can, so let’s add some limit tests.

def test_how_long_multiple_days_limit(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(days=20, hours=23, minutes=59, seconds=59)
self.assertEqual(post.how_long_ago(), '20 days ago')

def test_how_long_multiple_hours_limit(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(hours=23, minutes=59, seconds=59)
self.assertEqual(post.how_long_ago(), '23 hours ago')

def test_how_long_multiple_minutes_limit(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(minutes=59, seconds=59)
self.assertEqual(post.how_long_ago(), '59 minutes ago')

def test_how_long_multiple_seconds_limit(self):
creation = datetime(year=1966, month=6, day=6)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.datetime') as dt:
dt.now = mock.Mock()
dt.now.return_value = creation + timedelta(seconds=59, milliseconds=999)
self.assertEqual(post.how_long_ago(), '59 seconds ago')

You can be paranoid, but don’t be too paranoid. When your codebase grows, you’ll have a lot of tests. Running those tests will take time and some tests are just noises. If your tests take too much time to run, nobody will run them. You know what happens after…

Ok it’s good for today, see you!

PS: Some links you might be interested in:

--

--