A little Hacker News in Django (part 6)

Daniel Dương
5 min readJun 8, 2018

--

Let’s start by adding a LoginView. Good thing is, Django has already that covered for us: https://docs.djangoproject.com/en/2.0/topics/auth/default/

We just need to change hnews/urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
path('admin/', admin.site.urls),
path('posts/', include('hnews.posts.urls')),
path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
]

Now, we need to set where the login view will redirect. In settings.py

LOGIN_REDIRECT_URL = '/posts/'

Let’s add the template for the login view (copy pasta from Django’s documentation).

$ mkdir -p templates/registration
$ touch templates/registration/login.html
$ cat templates/registration/login.html
<html>
<body>
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}

{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>

<input type="submit" value="login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
</body>
</html>

To log in, go to this url: http://localhost:8000/accounts/login/

Let’s modify our list template.

<html>
<body>
{% if user.is_authenticated %}
{{ user }}
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

{% 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 we can check if we’re are logged in. Let’s add a log out view.

urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
path('admin/', admin.site.urls),
path('posts/', include('hnews.posts.urls')),
path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
]

settings.py

LOGOUT_REDIRECT_URL = '/posts/'

list.html

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

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

OK, we got this part now. Let’s add the ability to upvote. First let’s add VueJS.

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

<div id="app">
<ul>
<li v-for="post in posts">
[[ post.title ]] - [[ post.how_long_ago ]] - [[ post.domain_name ]]
</li>
</ul>
</div>

<!-- development version, includes helpful console warnings -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data: {
posts: {{ posts }},
}
})
</script>
</body>
</html>

Let’s modify our view to send a JSON to the template.

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

def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
context['posts'] = json.dumps([
{
'title': post.title,
'how_long_ago': post.how_long_ago(),
'domain_name': post.get_domain_name(),
}

for post in context['posts']
])
return context

If we go to http://localhost:8000/posts/, it doesn’t work. When you look at the HTML of the page, you see that the JSON is escaped.

var app = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data: {
posts: [{&quot;title&quot;: &quot;Microsoft Is Said to Have Agreed to Acquire GitHub&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;bloomberg.com&quot;}, {&quot;title&quot;: &quot;GitLab sees huge spike in project imports&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;monitor.gitlab.net&quot;}, {&quot;title&quot;: &quot;Facebook Gave Device Makers Deep Access to Data on Users and Friends&quot;, &quot;how_long_ago&quot;: &quot;20 hours ago&quot;, &quot;domain_name&quot;: &quot;nytimes.com&quot;}],
}
})

Let’s fix that by using the safe filter.

<script>
var app = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data: {
posts: {{ posts|safe }},
}
})
</script>

Now it works! Let’s send some data to the template to upvote.

def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
context['posts'] = json.dumps([
{
'title': post.title,
'how_long_ago': post.how_long_ago(),
'domain_name': post.get_domain_name(),
'upvoted': post.upvotes.filter(id=self.request.user.id).count() > 0,
'upvote_url': reverse('posts:set_upvoted_post', kwargs={'post_id': post.id}),
}
for post in context['posts']
])
return context

Now for the template:

<html>
<body>
{% if user.is_authenticated %}
{{ user }} - <a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}

<div id="app">
<ul>
<li v-for="post in posts">
[[ post.title ]] - [[ post.how_long_ago ]] - [[ post.domain_name ]]
{% if user.is_authenticated %}
- <span v-if="post.upvoted">Upvoted</span><span v-else>Not Upvoted</span>
- <button v-on:click="upvote(post)">Toggle upvoted</button>
{% endif %}
</li>
</ul>
</div>

<!-- development version, includes helpful console warnings -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie !== '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

function csrfSafeMethod(method) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}

var csrftoken = getCookie('csrftoken');

$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
}
});

var app = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data: {
posts: {{ posts|safe }},
},
methods: {
upvote: function (post) {
$.post({
url: post.upvote_url,
data: JSON.stringify({
upvoted: !post.upvoted
}),
success: function (data, text_status, jq_XHR) {
post.upvoted = !post.upvoted
},
})
}
}
})
</script>
</body>
</html>

We’re using some code from here to send the CSRF token when we’re doing our AJAX call.

When testing the upvote, I had a bug, my user kept logging out. Then I realized that I was deleting the user each time I was unupvoting.

The many to many manager returns instances of User not instances of PostUpvote. I’ll remove the manager so that I don’t get confused. My models.py looks like this now:

from datetime import timedelta
from urllib.parse import urlparse

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


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()
title = models.CharField(max_length=256)

def how_long_ago(self):
how_long = timezone.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'

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

def set_upvoted(self, user, *, upvoted):
if upvoted:
PostUpvote.objects.get_or_create(post=self, user=user)
else:
self.upvotes.filter(user=user).delete()


class PostUpvote(models.Model):
post = models.ForeignKey(Post, related_name='upvotes', on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name='post_upvotes', on_delete=models.CASCADE)

class Meta:
unique_together = ('post', 'user')


class Comment(models.Model):
creation_date = models.DateTimeField(auto_now_add=True)
creator = models.ForeignKey(
User,
related_name='comments',
on_delete=models.SET_NULL,
null=True,
)
post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
parent = models.ForeignKey('Comment', related_name='replies', on_delete=models.CASCADE, null=True, default=None)
content = models.TextField(null=True)

def set_upvoted(self, user, *, upvoted):
if upvoted:
CommentUpvote.objects.get_or_create(comment=self, user=user)
else:
self.upvotes.filter(user=user).delete()


class CommentUpvote(models.Model):
comment = models.ForeignKey(Comment, related_name='upvotes', on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name='comment_upvotes', on_delete=models.CASCADE)

class Meta:
unique_together = ('comment', 'user')

I had to also modify my get_context_data :

def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
context['posts'] = json.dumps([
{
'title': post.title,
'how_long_ago': post.how_long_ago(),
'domain_name': post.get_domain_name(),
'upvoted': post.upvotes.filter(user=self.request.user).count() > 0,
'upvote_url': reverse('posts:set_upvoted_post', kwargs={'post_id': post.id}),
}
for post in context['posts']
])
return context

OK it’s good for today!

See ya!

--

--