A little Hacker News in Django (part 3)
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:
- A little Hacker News in Django (Part 1)
- A little Hacker News in Django (Part 2)
- A little Hacker News in Django (Part 4)
- A little Hacker News in Django (Part 5)
- A little Hacker News in Django (Part 6)
- A little Hacker News in Django (Part 7)
- A little Hacker News in Django (Part 8)
- A little Hacker News in Django (Part 9)
- Github repository: hnews