How I Coded a Hacker News Clone in Django

5/5 - (5 votes)

Have you heard of the Hacker News website? I believe you do. If you haven’t though, just take a look at it.

In this tutorial, we will build a web application similar to the Hacker News website using the Django web framework.

This is going to be an interesting project which no doubt, will help you master the Django web framework.

The Hacker News website

According to the website, Hacker News is a social news site where one can share anything that β€˜gratifies one’s intellectual curiosity.’

The web app we are about to design will have all the main features of the website. This includes everything in the top navigation bar, individual posts, and user authentication.

Getting Started

If you have been following my Django project tutorials, the process of starting a Django project should be familiar. Hence, I’m not going to repeat them in this project. If you are not yet familiar with the process, please read this article:

πŸ‘¨β€πŸ’» Recommended: How I Created an URL Shortener App Using Django

You are free to use any name of your choice for the project and app folders. The only thing I want to point out is that you should make sure you create the folders in your current directory unless you know what you are doing.

You can always check my GitHub page for the source code.

Coding the Models

There will be four models for this web application:

  • The Post model for storing post-related information,
  • the Vote model for storing upvotes made by readers,
  • the Comment model for storing comments on each post, and finally,
  • the inbuilt User model to store account information of users.
from django.db import models
from django.contrib.auth.models import User

class Post(models.Model):
    title = models.CharField("HeadLine", max_length=256, unique=True)
    creator = models.ForeignKey(User, on_delete= models.SET_NULL, null=True)
    created_on = models.DateTimeField(auto_now_add=True)
    url = models.URLField("URL", max_length=256,blank=True)
    description = models.TextField("Description", blank=True)
    votes = models.IntegerField(null=True)
    comments = models.IntegerField(null=True)

    def __str__(self):
        return self.title

    def count_votes(self):
        self.votes = Vote.objects.filter(post = self).count()

    def count_comments(self):
        self.comments = Comment.objects.filter(post = self).count()

We import the User model to identify the person posting the news.

The Post model which extends the models.Model class contains the title of the news headline, the person posting the news, the date it was created, the link to the source of the headline news being shared, and a brief summary of what the news is all about.

The ForeignKey creates a many-to-one relationship. A given user can be the creator of many post. If a user is deleted, the value of the field is set to null. There are also fields to store the number of votes and comments made. They will receive the number when the methods are run.

The methods filter the votes and comments based on a given post and update the fields with the number of votes and comments, respectively. Let us now create the Vote and Comment classes.

class Vote(models.Model):
    voter = models.ForeignKey(User, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, on_delete=models.CASCADE)

    def __str__(self):
        return f"{self.voter.username} upvoted {self.post.title}"

Both the voter and the post fields have a ForeignKey which has been explained already. When the voter is removed, the vote(s) also gets deleted. When the post related to the vote(s) is deleted, the votes will be deleted as well. Finally, the Comment model.

class Comment(models.Model):
    creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    content = models.TextField()
    identifier = models.IntegerField()
    parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True)

    def __str__(self):
        return f"Comment by {self.creator.username}"

This is the same as the previous models. The identifier field stores a unique identifier for each comment. But there is something that looks somehow complicated. That is the parent field. The ForeignKey is creating a relationship with itself. It is referencing another comment, kind of a parent-child relationship.

For example, if a comment is made, it is the parent comment. If another comment is made as a response to the previous comment, it is now a child comment. If the parent comment is deleted, the value of the parent field for all child comments will be set to null. This means that the child comments will no longer have a parent comment.

We now perform migrations on the model.

python3 manage.py makemigrations
python3 manage.py migrate

Views, URLs and Templates

Create templates and static folders. Register them in the settings.py file. Under the TEMPLATES section, add these to DIRS :

 [os.path.join(BASE_DIR, 'templates')],

For the static folder, make a one-line change to the settings.py file after the STATIC_URL.

STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]

Let’s start creating the view functions. Go to your views.py file and add these:

from django.shortcuts import render, redirect
from .models import Post, Comment, Vote

def PostListView(request):
    posts = Post.objects.all()
    for post in posts:
        post.count_votes()
        post.count_comments()

    context = {
        'posts': posts,
    }
    return render(request,'postlist.html',context)

We query the database and retrieve all posts. For each given post, we count the number of votes and comments. By calling the methods, we update the values of the votes and comments fields.

Finally, the post object(s) is passed to a dictionary to be rendered on a template.

def NewPostListView(request):
    posts = Post.objects.all().order_by('-created_on')
    for post in posts:
        post.count_votes()
        post.count_comments()
    context = {
        'posts': posts,
    }
    return render(request,'newpostlist.html', context)

This view function is similar to the previous one. Thus, it will have the same template as the previous view function. The only difference is that it retrieves posts based on the most recent ones. The suffix before created_on denotes descending order, from newest to oldest.

Let’s create endpoints for these view functions. First of all, include the app-level URLs we will soon create in your project-level URLs. Then, create a urls.py file in your app folder.

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

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

Make sure that yours correspond to your app name. For the app-level URLs:

from django.urls import path
from .views import  PostListView, NewPostListView

urlpatterns = [
    path('', PostListView, name='home'),
    path('new', NewPostListView, name='new_home'),
]

Next are the templates. Create a base.html file to be inherited by other templates.

{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="{% static 'css/styles.css' %}">
  </head>
  <body>
    {% block content %}
    {% endblock %}
  </body>
</html> 

The static file is loaded at the top. We then link the CSS files. So, make sure you create a css folder inside the static folder. Alright, for the post_list.html:

{% extends 'base.html' %}
{% block content %}

<div class="topnav">
  <a class="active" href="{ % url 'home' %}">Hacker News</a>
  <a href="{% url 'new_home'%}">New</a>
  <a href="{% url 'past_home'%}">Past</a>
  <a href="{% url 'submit'%}">Submit</a>

  {% if request.user.is_authenticated %}
    <div class="topnav-right">
      <a href="{% url 'signout' %}">Sign Out </a>
    </div>
  {% else %}
    <div class="topnav-right">
      <a href="{% url 'signin' %}">Sign In </a>
    </div>
  {% endif %}
</div>

<div class="w3-panel w3-light-grey w3-leftbar w3-border-grey">
  <ol>
{% for post in posts %}

  <li><p><a href = "{{post.url}}"><strong>{{post.title}}</strong></a> - <a href = "{% url 'vote' post.id %}">Upvote</a> - <a href = "{% url 'dvote' post.id %}">Downvote</a></p>

  {% if post.creator == request.user%}
    <p>{{post.votes}} votes | Created {{post.created_on}}| <a href = "{% url 'user_info' post.creator.username %}">{{post.creator.username}}</a> | <a href="{% url 'post' post.id %}"> {{post.comments}} Comments</a> | <a href="{% url 'edit' post.id %}"> Edit</a></p></li>
  {%else %}
    <p>{{post.votes}} votes | Created {{post.created_on}}| <a href = "{% url 'user_info' post.creator.username %}">{{post.creator.username}}</a> | <a href="{% url 'post' post.id %}"> {{post.comments}} Comments</a></p></li>
  {% endif %}

{% endfor %}
</ol>
</div>

{% endblock %}

We extend the base.html at the top. The navigation bar contains links to the home page, new posts, past posts, and the submit page to add new posts. If a user is logged in, a sign-out link will be displayed otherwise, the user will be required to log in to see posts from the website.

Then, a for loop is performed to display all the information from the database.

If the person viewing this page is the same as the person who posted the news, all information related to the post will be displayed. These include the number of votes, the person who posted the news, comments made and the option to edit the post.

If the person viewing the page is not the same person who posted the news, there will be no option to edit the post.

Can you see how Django templating language using the { … } and {% … % } blocks reduce the amount of code we have to write?

The final template for this series is the newpost.html. It is the same as the previous one. Feel free to check my GitHub page for the source code.

Conclusion

This marks the end of the first part of this project tutorial. For this series, we created a model for our database, and added two view functions and endpoints to map a given request to the appropriate view function. Finally, the templates, to be displayed to the user.

We still have a lot to do and they can’t be covered in one article. In the next series, we will create more view functions, endpoints and templates for the Hacker News clone Django website. Stay tuned!