A little Hacker News in Django (part 4)

Daniel Dương
7 min readJun 6, 2018

--

So, where were we? Last time, we finished with the how_long_ago method. We wrote tests, and now we can use it in our template.

<html>
<body>
{% if posts %}
<ul>
{% for post in posts %}
<li>{{ post.title }} - {{ post.how_long_ago }}</li>
{% endfor %}
</ul>
{% endif %}
</body>
</html>

Now, when I go to http://localhost:8000/posts/, I get an error.

can't subtract offset-naive and offset-aware datetimes

Turns out, I didn’t think of this thing when I wrote the tests. Now, let’s change our tests to catch this bug. Since we mocked datetime.now(), I don’t really know how we can test that, but we can at least put the timezone info.

from datetime import datetime, timedelta, timezone
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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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')

def test_how_long_multiple_days_limit(self):
creation = datetime(year=1966, month=6, day=6, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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, tzinfo=timezone.utc)

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')

Now, for our method we need to put the time zone information. I know that in the settings, we have the time zone info.

TIME_ZONE = 'UTC'

So I expect Django to provide us something to give the time using our time zone. A little look at the documentation and I’ve found this: https://docs.djangoproject.com/en/2.0/topics/i18n/timezones/#naive-and-aware-datetime-objects

Let’s replace datetime.now() by timezone.now(). Now, we need to change our tests again. Our patch call needs to patch the right object.

from datetime import datetime, timedelta, timezone
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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

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

def test_how_long_multiple_days_limit(self):
creation = datetime(year=1966, month=6, day=6, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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, tzinfo=timezone.utc)

post = Post(creation_date=creation)
with mock.patch('hnews.posts.models.timezone') 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')

Now, if we go to http://localhost:8000/posts/, it should work!

What should we do next? In Hacker News, the domain name of the link is displayed. We should write a get_domain_name method. I don’t really know how to do the method yet, but I’m feeling TDD today (at least for this method). Let’s write a test.

def test_domain_name(self):
post = Post(url='https://techcrunch.com/2018/06/05/washington-sues-facebook-and-google-over-failure-to-disclose-political-ad-spending/')
self.assertEqual(post.get_domain_name(), 'techcrunch.com')

Now if we run the test.

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E.............
======================================================================
ERROR: test_domain_name (hnews.posts.tests.PostTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/daniel/Code/hnews/hnews/posts/tests.py", line 129, in test_domain_name
self.assertEqual(post.get_domain_name(), 'techcrunch.com')
AttributeError: 'Post' object has no attribute 'get_domain_name'
----------------------------------------------------------------------
Ran 14 tests in 0.008s
FAILED (errors=1)
Destroying test database for alias 'default'...

Let’s fix the test.

def get_domain_name(self):
pass

Now if we run the test again:

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.............
======================================================================
FAIL: test_domain_name (hnews.posts.tests.PostTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/daniel/Code/hnews/hnews/posts/tests.py", line 129, in test_domain_name
self.assertEqual(post.get_domain_name(), 'techcrunch.com')
AssertionError: None != 'techcrunch.com'
----------------------------------------------------------------------
Ran 14 tests in 0.010s
FAILED (failures=1)
Destroying test database for alias 'default'...

Now, how are we going to solve the URL problem? I’m pretty sure someone has already solved this problem. So let’s google get domain name from url django. This stack overflow answer seems good, so let’s try it: https://stackoverflow.com/a/18332128.

ModuleNotFoundError: No module named 'urlparse'

Ok let’s look at Python’s documentation. https://docs.python.org/3/library/urllib.parse.html?highlight=urlparse#urllib.parse.urlparse. We need to do this now:

from urllib.parse import urlparse

Let’s run the tests:

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..............
----------------------------------------------------------------------
Ran 14 tests in 0.008s
OK
Destroying test database for alias 'default'...

Ok good. Let’s check that everything is good with a subdomain.

def test_domain_name_with_subdomain(self):
post = Post(url='https://blog.mozilla.org/nnethercote/2018/06/05/how-to-speed-up-the-rust-compiler-some-more-in-2018/')
self.assertEqual(post.get_domain_name(), 'blog.mozilla.org')

Now let’s run the tests again:

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...............
----------------------------------------------------------------------
Ran 15 tests in 0.008s
OK
Destroying test database for alias 'default'...

Everything is fine, we can put the domain name in our template.

<html>
<body>
{% if posts %}
<ul>
{% for post in posts %}
<li>{{ post.title }} - {{ post.how_long_ago }} - {{ post.get_domain_name }}</li>
{% endfor %}
</ul>
{% endif %}
</body>
</html>

Now I’ve realized something. My list looks like this:

  • Microsoft Is Said to Have Agreed to Acquire GitHub — 1 day ago — www.bloomberg.com
  • GitLab sees huge spike in project imports — 1 day ago — monitor.gitlab.net
  • Facebook Gave Device Makers Deep Access to Data on Users and Friends — 1 day ago — www.nytimes.com

But Hacker News doesn’t put www in the domain name. So we need to change our tests.

def test_domain_name_with_www(self):
post = Post(url='https://www.livescience.com/61627-ancient-virus-brain.html')
self.assertEqual(post.get_domain_name(), 'livescience.com')

Now if we run the tests:

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..F.............
======================================================================
FAIL: test_domain_name_with_www (hnews.posts.tests.PostTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/daniel/Code/hnews/hnews/posts/tests.py", line 137, in test_domain_name_with_www
self.assertEqual(post.get_domain_name(), 'livescience.com')
AssertionError: 'www.livescience.com' != 'livescience.com'
- www.livescience.com
? ----
+ livescience.com
----------------------------------------------------------------------
Ran 16 tests in 0.021s
FAILED (failures=1)

Now to fix it:

def get_domain_name(self):
name = urlparse(self.url).hostname
if name.startswith('www.'):
return name[len('www.'):]
else:
return name

And the tests should pass:

$ ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
................
----------------------------------------------------------------------
Ran 16 tests in 0.008s
OK
Destroying test database for alias 'default'...

Ok it’s good for today. We’ve fixed some time zone issue. We have done a helper method for the domain name. We’ve done some TDD. There is still a lot to do, but… one day at a time.

See ya!

PS: Some links you might be interested in:

--

--