CommonLounge Archive

Hands-on Assignment: Creating a Reddit Clone Part 1

May 17, 2018

For this project, we’ll get practice creating a real-world and more complicated web application — our own Reddit clone. It will showcase a variety of features including subreddits, posts, comments, voting, and even search. We will explore Django concepts such as abstract models, advanced querying, signals and receivers, and more!

Project Goals

  • Practice data modeling with a real-world, complex application
  • Using ManyToManyFields, ForeignKeys, and GenericForeignKey.
  • Abstract models and inheritance
  • Unique database constraints
  • Custom primary keys using UUID’s
  • Advanced querying using the Django ORM as well as F expressions and Q operators.
  • Django Signals and Receivers
  • Django modular templates
  • Many Django best practices and caveats

Step 0: Setup

Go ahead and setup your project. Download the bare bones code here. (If you’re using Django 1.11, use the starter code here). Then run the following code:

python3 -m venv myvenv
source myvenv/bin/activate
pip install -r requirements.txt

Now, we’re ready to get cracking!

Step 1: Exercise in Object Modeling

One of the first things you’ll do when you begin a new Django project is think about the actual object model. How will you translate the project’s objectives and requirements into your models? Only then will you think about the view functions, templates, etc.

We want to create a simple Reddit clone. Let’s break the requirements further.

Subreddit

  • Each subreddit contains a collection of posts.

Posts

  • All posts have a title.
  • A post can further have a link, text description, or both.
  • Posts are submitted by users
  • Each post can have upvotes or downvotes. Posts should record the number of upvotes and downvotes they have
  • A post could potentially be posted to multiple subreddits.
  • Posts should store the number of comments they have.

Comment

  • Each post can have users who comment on it.
  • A comment can also be a reply to any other comment.
  • A comment can also have upvotes or downvotes.

Now that we have these requirements set up, take a shot at coming up with the models for yourself in reddit/models.py. We will cover what we’ve done in the next section.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Step 2: A Sample Object Model

We will walk you through how to think about creating these models.

Creating the Base Model

For any of the objects that we will be creating such as SubReddit, Post, or Comment, we will want to store some common information such as date the object was created.

All of these objects will have id’s, but we don’t just want to store an auto-incrementing key, since then outside users will know exactly how many posts, comments, etc. are on the platform. Instead we will opt to replace this id with our own custom eid.

Here’s what our BaseModel looks like:

from django.db import models
import uuid
class BaseModel(models.Model):
    eid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    date_created = models.DateTimeField(auto_now_add=True, db_index=True)
    class Meta: abstract = True
    # Helper method, so that we don't have to do the existence check every time.
    @classmethod
    def get_or_none(cls, **kwargs):
        try:
            return cls.objects.get(**kwargs)
        except cls.DoesNotExist:
            return None

UUIDs

We will use a UUID instead of an auto-increment key to represent the id’s of all our objects. A UUID looks like this: UUID('a911cdd8-b164-470e-a845-1822474f39db'). Django has a built-in UUIDField that we can set as the primary key. On line 2 you can see we set eid as the primary key and give it a default value of uuid.uuid4, which just generates a random uuid whenever the object is created.

Abstract Class

We’ll make BaseModel an abstract class so that all of the models we create can inherit from it. The great thing about abstract classes is that they don’t create another database table — it’s just an easy way to put the common information into a bunch of models without having to retype the logic again and again. On line 4 above, you can see that we declared the abstract class: class Meta: abstract = True.

**db_index**

db_index instructs Django to create an index on that column. Primary keys by default will automatically have an index created from them since they are unique keys, i.e. no two objects can have the same primary key. Indexes are useful because they allow for fast querying. In our case if we want to filter by the date_created of a user, an index will really help.


We also added a get_or_none helper method. As you know, when you query for an object using get, if it doesn’t exist, Django throws an error. By writing this helper method, we catch the error and return None if the object doesn’t exist. We’ll use this method at various points across this project.

Creating the Votable Object

From the requirements in the last step, we know that both comments and posts will need to have upvote_count and downvote_count. This looks like another great use case for abstract classes!

class Votable(BaseModel):
    upvote_count = models.PositiveIntegerField(default=0)
    downvote_count = models.PositiveIntegerField(default=0)
    class Meta: abstract = True
    def get_score(self):
        return self.upvote_count - self.downvote_count

We use a new field here called PositiveIntegerField for both the counts. It is useful when we want to store integers greater than or equal to zero — a perfect fit for storing counts, since we can’t have negative counts.

Also note that Votable inherits from BaseModel so it automatically gets the eid, and date_created fields. You can easily chain abstract classes in this way.

We also added a get_score function that might come in handy later as we show which post’s are doing the best in our templates. For now, we’ll just do a simple difference between the upvote and downvote counts.

Creating the Post Object

Let’s move on to constructing the Post object. We know it will need the following attributes:

  • title — this can’t be null and is required
  • submitter — this will be a ForeignKey to a user.
  • url — A url to store for the post. Note that this is not required for the user to enter, so we set both null and blank to True (more on this later).
  • text — the text of the post. Again, this is not required.
  • comment_count — the number of comments this post has.

We will have our Post class inherit from Votable so that we automatically inherit all the fields we defined earlier.

from django.conf import settings
class Post(Votable):
    title = models.CharField(max_length=200)
    submitter = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='posts_submitted', on_delete=models.CASCADE)
    url = models.URLField('URL', max_length=200, null=True, blank=True)
    text = models.TextField(blank=True, null=True)
    comment_count = models.PositiveIntegerField(default=0)
    def children(self):
        return self.comments.filter(parent=None)
    def __str__(self):
        return str(self.eid) + ": " + self.title

Blank vs Null

What’s the difference between null and blank? null controls whether the column value in the database can have a null value or not. blank determines whether that field is required in forms, so by setting it to False, the user is not required to enter it either in the form that we display in our template or on the admin.

**related_name**

It’s considered good Django practice to add a [related_name](https://docs.djangoproject.com/en/1.11/ref/models/fields/#django.db.models.ForeignKey.related_name) to all ForeignKey’s, ManyToManyField’s, etc. related_name is the name to use for the reverse relation — i.e. the relation from the related object back to this one. So in the example above, if we started off with a user object, and did user.posts_submitted.all(), we would be able to access all the posts a user had submitted.

**__str__**

In Django, whenever you interact with the object in the admin, there needs to be some way to represent the object as a string. By defining this function, you can control what that string representation is. For us, we will simply append the eid and the title together. We have to convert the UUID object into a string, thus we do str(self.eid).

**children**

It will be useful later on to know the direct children of this post. This is super simple to do as we can just filter for all the comments that this post has that have no parents. We’ll cover more on the parent of a comment below.

on_delete

This argument is required in Django version 2.0 and optional in Django 1.11 and below. It controls the behavior of this object (i.e. the Post object) when the referenced object is deleted (i.e. when the submitter is deleted in this case). There are several options — we’ve highlighted the most common ones below.

  • CASCADE — Cascade the deletes. This means that when the submitter is deleted, the Post object will be deleted as well.
  • PROTECT — Prevent deletion of this object by raising an error.
  • SET_NULL — Set the value of the referenced object to null. This means that when the submitter is deleted, the Post object’s submitter value will be null.

In our case, we will CASCADE the deletes since if the submitter is deleted, we want to delete the post as well.

Creating the Comment Object

The interesting bit about the Comment object is that it needs to store some notion of hierarchy, since it may be a reply to another comment. There is a simple way to store this — we use a parent field which is just a ForeignKey to another Comment. You can represent self-referential ForeignKey’s by using 'self'. Now if the parent of this comment is None, then we know it’s a top level comment. Otherwise, if it refers to another Comment object, we know that this comment is a reply to that parent comment.

Here is the code for the comment object. Note that it also inherits from Votable since we want all those common fields.

class Comment(Votable):
    post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
    author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='comments_authored', on_delete=models.CASCADE)
    text = models.TextField()
    parent = models.ForeignKey('self', related_name='children', null=True, blank=True, on_delete=models.CASCADE)
    def __str__(self):
        return str(self.eid) + ": " + self.text

Creating the SubReddit Object

The interesting thing about a SubReddit object that we haven’t encountered yet is that it will need to have a reference to all the posts in it as well as all the moderators.

Many-to-Many (M2M) Relationships

This is where the [ManyToManyField](https://docs.djangoproject.com/en/1.11/topics/db/examples/many_to_many/) comes into play — it represents a Many-to-Many (M2M) relationship. In our case, a post can be in many subreddits, and a subreddit can have many posts. Django stores this relationship in a separate table that will have a ForeignKey to both the Post and the SubReddit table. Django will create and handle this table automatically for you if you use a ManyToManyField. However sometimes you want to have the flexibility to create the table yourself.

In these cases, Django allows you, the programmer, to manually define this table and let Django know about it via the through argument passed to ManyToManyField.

It might be useful for us to know when a post was submitted to a subreddit so we want it to inherit from BaseModel.

We will define a custom table for the M2M relationship called SubRedditPost.

Now, the moderators will also be a M2M relation, but in this case, we just let Django do it’s default M2M behavior. Thus, we won’t pass in any through table.

Check out our implementation below:

class SubReddit(BaseModel):
    name = models.CharField(max_length=200)
    posts = models.ManyToManyField('Post', related_name='subreddits', blank=True, through='SubRedditPost')
    cover_image_url = models.URLField('Cover Image URL', max_length=200, blank=True, null=True )
    moderators = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='subreddits_moderated')
    def __str__(self):
        return self.name
class SubRedditPost(BaseModel):
    subreddit = models.ForeignKey('SubReddit', related_name='posts_set', on_delete=models.CASCADE)
    post = models.ForeignKey('Post', related_name='subreddits_set', on_delete=models.CASCADE)
    class Meta: unique_together = ['subreddit', 'post']

unique_together

A final thing to note here is the class Meta: unique_together = ['subreddit', 'post']. This means that Django will add a unique constraint such that the set of these fields, taken together, must be unique. In other words, a subreddit and post uniquely identify a SubRedditPost object. If you try creating another object with the same subreddit and post, Django will throw an error.

Creating the UserVote Object

Finally, we need a way to store the user votes for a comment or post.

We need to know whether a vote is either a UP_VOTE or a DOWN_VOTE. In the code snippet below, you can see that we defined constants to represent both types of votes. We can then defined the VOTE_TYPE tuple and pass that to a CharField with the choices variable. Whenever we use this field in a form, Django will automatically show a select box with the choices we passed in.

Now, since the user can vote on either a comment or post, we could have created two separate tables, i.e. a UserPostVote and a UserCommentVote table. Both these tables would have a ForeignKey to a User and to either a Post or Comment. But in this case, we see that there is a lot of shared logic between Post and Comment. This is exactly why we used the Votable abstract class to centralize the logic in one place.

Thus, we will use [GenericForeignKey](https://docs.djangoproject.com/en/1.11/ref/contrib/contenttypes/#generic-relations)’s to help solve this issue.

To understand how these work, we first need to understand the contenttypes framework. By default Django provides a ContentType model, which basically stores information about all the models stored in your project. Each object will contain the app_label, model, and name. For example, the app_label for our app is reddit.

To implement a GenericForeignKey, we:

  1. Give our model a ForeignKey to ContentType. Generally, this field is named content_type.
  2. Give our model a field that can store the primary key value of the models we’ll be relating to. In our case this will be a UUIDField and this will contain the eid of either the Post or Comment object. Typically, this field is named object_id.

Here is our implementation for the UserVote object. You can see the implementation below for GenericForeignKey on lines 12-14. Note the unique_together in this case takes three fields.

from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
class UserVote(BaseModel):
    UP_VOTE = 'U'
    DOWN_VOTE = 'D'
    VOTE_TYPE = (
        (UP_VOTE, 'Up Vote'),
        (DOWN_VOTE, 'Down Vote')
    )
    voter = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='votes', on_delete=models.CASCADE)
    #Generic Foreign Key config
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, on_delete=models.CASCADE)
    object_id = models.UUIDField()
    content_object = GenericForeignKey('content_type', 'object_id')
    vote_type = models.CharField(max_length=1, choices=VOTE_TYPE)
    class Meta: unique_together = ['voter', 'object_id', 'content_type']

The Final Object Model

Here are all the models put together with the relevant imports.

from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings
import uuid
class BaseModel(models.Model):
    eid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    date_created = models.DateTimeField(auto_now_add=True, db_index=True)
    class Meta: abstract = True
    # Helper method, so that we don't have to do the existence check every time.
    @classmethod
    def get_or_none(cls, **kwargs):
        try:
            return cls.objects.get(**kwargs)
        except cls.DoesNotExist:
            return None
class SubReddit(BaseModel):
    name = models.CharField(max_length=200)
    posts = models.ManyToManyField('Post', related_name='subreddits', blank=True, through='SubRedditPost')
    cover_image_url = models.URLField('Cover Image URL', max_length=200, blank=True, null=True )
    moderators = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='subreddits_moderated')
    def __str__(self):
        return self.name
class SubRedditPost(BaseModel):
    subreddit = models.ForeignKey('SubReddit', related_name='posts_set', on_delete=models.CASCADE)
    post = models.ForeignKey('Post', related_name='subreddits_set', on_delete=models.CASCADE)
    class Meta: unique_together = ['subreddit', 'post']
class Votable(BaseModel):
    upvote_count = models.PositiveIntegerField(default=0)
    downvote_count = models.PositiveIntegerField(default=0)
    class Meta: abstract = True
class Post(Votable):
    title = models.CharField(max_length=200)
    submitter = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='posts_submitted', on_delete=models.CASCADE)
    url = models.URLField('URL', max_length=200, null=True, blank=True)
    text = models.TextField(blank=True, null=True)
    comment_count = models.PositiveIntegerField(default=0)
    def children(self):
        return self.comments.filter(parent=None)
    def __str__(self):
        return str(self.eid) + ": " + self.title
class Comment(Votable):
    post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE)
    author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='comments_authored', on_delete=models.CASCADE)
    text = models.TextField()
    parent = models.ForeignKey('self', related_name='children', null=True, blank=True, on_delete=models.CASCADE)
    def __str__(self):
        return str(self.eid) + ": " + self.text
class UserVote(BaseModel):
    UP_VOTE = 'U'
    DOWN_VOTE = 'D'
    VOTE_TYPE = (
        (UP_VOTE, 'Up Vote'),
        (DOWN_VOTE, 'Down Vote')
    )
    voter = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='votes', on_delete=models.CASCADE)
    #Generic Foreign Key config
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, on_delete=models.CASCADE)
    object_id = models.UUIDField()
    content_object = GenericForeignKey('content_type', 'object_id')
    vote_type = models.CharField(max_length=1, choices=VOTE_TYPE)
    class Meta: unique_together = ['voter', 'object_id', 'content_type']

Go ahead and run migration by going to your terminal window and typing in:

python manage.py makemigrations
python manage.py migrate

You may also want to create a superuser so that you can login later.

python manage.py createsuperuser

Step 3: Create the Basic Forms

Let’s begin by creating the forms we’ll need for posts and comments. Create a file called forms.py located at reddit/forms.py.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Our Forms

from django import forms
from .models import *
class PostForm(forms.ModelForm):
    subreddits = forms.ModelMultipleChoiceField(queryset=SubReddit.objects.all())
    class Meta:
        model = Post
        fields = ('title', 'text', 'url', 'subreddits')
class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ('text',)

We know we’ll need a way to pick which subreddits we want the post to be posted to, so we use a ModelMultipleChoiceField. If you pass it a queryset (all the SubReddit objects in our case), then when we display this form, Django will automatically render the select box properly with all the SubReddit objects.

Step 4: Create the basic views

Let’s begin by creating the basic views to do the following

  • create a post → post_new(request)
  • edit a post → post_edit(request, pk)
  • view a posts details → post_detail(request, pk)
  • view a list of all posts → post_list(request)
  • view all the posts in a subreddit → sub_detail(request, pk)

These functions should be fairly similar to the functions you saw earlier for the blog, with a few interesting tidbits. Put these functions in reddit/views.py.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

post_list

from .models import *
from .forms import *
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
def post_list(request):
    posts = Post.objects.all().order_by('-date_created')
    return render(request, 'reddit/post_list.html',  {'posts': posts})

post_new

@login_required
def post_new(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.submitter = request.user
            post.save()
            for subreddit_id in request.POST.getlist('subreddits'):
                SubRedditPost.objects.create(subreddit_id=subreddit_id, post=post)
            return redirect('post_detail', pk=post.pk)
    else:
        form = PostForm()
    return render(request, 'reddit/post_edit.html', {'form': form, 'is_create': True})

A few important things to note in this function:

  • We need to create SubRedditPost objects to store all the subreddits a post is being published in. We can use request.POST.getlist to get the list of all the subreddit id’s. Remember that we defined the subreddits field in the PostForm above.
  • We use reddit/post_edit.html for the post creation template as well, since it is very similar to the post editing template. The only difference is that the form will already have some pre-filled values in the edit template. We pass a is_create variable so that the template can show Edit Post vs. New Post as a heading on the page.

post_edit

@login_required
def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.method == "POST":
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save(commit=False)
            post.save()
            return redirect('post_detail', pk=post.pk)
    else:
        form = PostForm(instance=post, initial={'subreddits' : post.subreddits.all()})
    return render(request, 'reddit/post_edit.html', {'form': form, 'is_create': False})

A few important things to note in this function:

  • We make sure to not change the author for the post on an edit. However we didn’t do any sort of check to make sure that only the submitter can edit his or her post. This is left as an exercise to you! Hint: use the post object and confirm that the post.submitter is the same as request.user. If not, then don’t save the object, and just reject it. If you have questions about this step, leave a reply below :).
  • On line 12, we pass the post object to the PostForm, so that the form is pre-filled with the existing post’s values. We also set the [initial](https://docs.djangoproject.com/en/1.11/ref/forms/fields/#initial) value of the subreddits, so that when the user goes to edit a post, the subreddits they selected in the past will be automatically selected.

post_detail

def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, 'reddit/post_detail.html', {'post': post})

sub_detail

def sub_detail(request, pk):
    sub = get_object_or_404(SubReddit, pk=pk)
    return render(request, 'reddit/sub_detail.html', {'sub': sub})

add_comment

@login_required
def add_comment(request, pk, parent_pk=None):
    post = get_object_or_404(Post, pk=pk)
    if request.method == "POST":
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = post
            comment.author = request.user
            comment.parent_id = parent_pk
            comment.save()
        return redirect('post_detail', pk=post.pk)
    else:
        form = CommentForm()
    return render(request, 'reddit/add_comment.html', {'form': form})

For this function, we want to be able to handle both adding a comment to a post and replying to another comment. In the first case, there will be no parent_pk passed in and in the second the parent_pk will be the eid of the comment object that this comment is replying to. On line 10, we simply set the parent_id of this comment and that’s all we need to do to store the comment hierarchy!

Step 5: Urls

Django 2.0 and above

from django.urls import path, include
from . import views
urlpatterns = [
    path('', views.post_list, name='post_list'),
    path('post/<uuid:pk>/', views.post_detail, name='post_detail'),
    path('sub/<uuid:pk>/', views.sub_detail, name='sub_detail'),
    path('post/new/', views.post_new, name='post_new'),
    path('post/<uuid:pk>/edit/', views.post_edit, name='post_edit'),
    path('post/<uuid:pk>/comment/', views.add_comment, name='add_comment_to_post'),
    path('post/<uuid:pk>/comment/<uuid:parent_pk>/', views.add_comment, name='add_reply_to_comment'),
]

We used <uuid:pk> to account for the requirements of a UUID string as stated above. Note that, earlier we used to use <int:pk>, but now that we’re using UUID’s, we need to let Django know the new format.


Django 1.11

Take a moment to come up with urls for the above views by yourself. Remember that the primary key is no longer just a number, but rather a UUID string. It will consist of numbers (0-9), alphabet(a-f), and dashes.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here is how we’d do it:

from django.conf.urls import url
from . import views
urlpatterns = [
    url(r'^$', views.post_list, name='post_list'),
    url(r'^post/(?P<pk>[0-9a-f-]+)/$', views.post_detail, name='post_detail'),
    url(r'^sub/(?P<pk>[0-9a-f-]+)/$', views.sub_detail, name='sub_detail'),
    url(r'^post/new/$', views.post_new, name='post_new'),
    url(r'^post/(?P<pk>[0-9a-f-]+)/edit/$', views.post_edit, name='post_edit'),
    url(r'^post/(?P<pk>[0-9a-f-]+)/comment/$', views.add_comment, name='add_comment_to_post'),
    url(r'^post/(?P<pk>[0-9a-f-]+)/comment/(?P<parent_pk>[0-9a-f-]+)/$', views.add_comment, name='add_reply_to_comment'),
]

We use [0-9a-f-]+ as the regular expression to account for the requirements of a UUID string as stated above.


Alright, back to the common instructions, regardless of Django version!

You may also have noticed that we have two different url paths for commenting: one called add_comment_to_post and another called add_reply_to_comment. They both still land up at views.add_comment, but by separating them, we can add the parent_pk as part of the url for adding replies to comment.

Step 6: Creating the basic templates

Great! Now it’s time to create our templates.

base.html

Let’s define our base template: base.html at reddit/templates/reddit/base.html. It’s very similar to the blog base template we created in the past.

{% load staticfiles %}
<html>
    <head>
        <title>Reddit 2.0</title>
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
        <link rel="stylesheet" href="{% static 'css/reddit.css' %}">
    </head>
    <body>
        <div class="page-header">
            {% if user.is_authenticated %}
                <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
                <p class="top-menu">{{ user.username }} <small><a class="glyphicon glyphicon-log-out logout" href="{% url 'logout' %}"></a></small></p>
            {% else %}
                <a href="{% url 'login' %}" class="top-menu"><span class="glyphicon glyphicon-lock"></span></a>
            {% endif %}
            <h1><a href="/">Reddit 2.0</a></h1>
        </div>
        <div class="content container">
            <div class="row">
                <div class="col-md-8">
                {% block content %}
                {% endblock %}
                </div>
            </div>
        </div>
    </body>
</html>

post_list.html

Try creating the post_list.html yourself. You’ll need to show all the posts along with their title, url, date, number of comments, a link to the each post, etc.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here’s our post_list.html.

{% extends 'reddit/base.html' %}
{% block content %}
    <div class="post-list">
        {% for post in posts %}
            <div class="post">
                <h2>
                    <a href="{% url 'post_detail' pk=post.pk %}">{{ post.title }}</a>
                    {% if post.url %}
                        <span class="url"><a href="{{post.url}}">{{post.url}}</a>
                    {% endif %}
                </h2>
                <div class="date">
                    {{ post.date_created }}
                    {% include "reddit/subs_posted.html" with post=post %}
                </div>
                {% if post.url %}
                    <a href="{{post.url}}">{{post.url}}</a>
                {% endif %}
                <p>{{ post.text|linebreaksbr }}</p>
                {% if  post.comment_count > 0 %}
                    <p><a href="{% url 'post_detail' pk=post.pk %}">{{ post.comment_count }} Comments</a></p>
                {% endif %}
            </div>
        {% endfor %}
    </div>
{% endblock %}

You’ll notice that on line 16, we use this line {% include "reddit/subs_posted.html" with post=post %}. We can use the built in [include](https://docs.djangoproject.com/en/1.11/ref/templates/builtins/#include) functionality to load a template (subs_posted.html in our case) and render it with the current context. You can pass in additional context using with, like we did above.

Here is the code for our subs_posted.html. It lists all of the subreddits a post was posted to, and links each of the subreddits name to the subreddit detail page.

{%if post.subreddits.count %}
    <span>Posted in</span>
    {% for sub in post.subreddits.all %}
        <a href="{% url 'sub_detail' pk=sub.pk %}">{{ sub.name }}</a>
    {% endfor %}
{%endif%}

post_edit.html

Again, this template is fairly straight-forward. We use the is_create variable we passed in in the post_new and post_edit view functions we defined earlier to show “New Post” vs. “Edit Post”.

{% extends 'reddit/base.html' %}
{% block content %}
    <h1>{% if is_create %} New post {% else %} Edit post {% endif %}</h1>
    <form method="POST" class="post-form">{% csrf_token %}
        {{ form.as_p }}
        <button type="submit" class="save btn btn-default">Save</button>
    </form>
{% endblock %}

post_detail.html

Try creating the post_detail.html yourself. You’ll need to show each post’s title, url if it exists, the date it was created, and all of it’s children.

The interesting challenge will be figuring out a good way to display the hierarchy of nested comments for a given post. You might want to play with margins or padding to show indentation for the nested comments.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

We’re first going to create a template to house a list of comments and all of its children. This template will be recursive, i.e. we will keep including the same template to house all of a particular comment’s children, and each of its children’s children and so on.

You can see that we take in a list of comments and for each of them we:

  • Show the comment author and the date of creation (line 4)
  • Show the comment text (line 5)
  • If it has some children, then we include the template again and pass it all of this comment’s children (line 6-8)

comment.html

{% block content %}
    {% for comment in comments %}
        <div class="comment">
            <div class="date"> <strong>{{ comment.author }}</strong> on {{ comment.date_created }}</div>
            <p>{{ comment.text|linebreaks }}</p>
            <a href="{% url 'add_reply_to_comment' pk=post.pk parent_pk=comment.pk %}">Add Reply</a>
             {% if comment.children.count > 0 %}
                {% include "reddit/comment.html" with comments=comment.children.all %}
            {% endif %}
        </div>
    {% endfor %}
{% endblock %}

Let’s dissect this template a bit.

  • Notice that on line 6, we pass both the post’s pk and the comment’s pk to the add_reply_to_comment url. This is how we’ll know which comment is being replied to and will thus be able to set the parent_id on the comment object accordingly.
  • We also only include the comment.html template (line 7-9) again if it has at least one children.
  • On line 8, we pass comments=comment.children.all to the same comments template…you can see the recursion here.

You might be wondering how the visual nesting will be happening. The template above could output something like:

<div class="comment">
    <div class="comment">
        <div class="comment">
            <div class="comment">
                ...
            </div>
        </div>
    <div class="comment">
        <div class="comment">
            <div class="comment">
                ...
            </div>
        </div>
    </div>
    <div class="comment">
        ...
    </div>
</div>

As you can see the comment div’s are nested within each other. If we simply add a margin to the div (something like .comment{ margin: 20px; } ), all the margin’s will add up and we will see the visual indentation!

Here is the post_detail.html, with the incorporated comment.html template. Notice how comments=post.children.all is passed to the reddit/comment.html template. Remember that children method that we defined on the Post model above. This is where it comes in handy!

{% extends 'reddit/base.html' %}
{% block content %}
    <div class="post">
        {% if user.is_authenticated %}
            <a class="btn btn-default" href="{% url 'post_edit' pk=post.pk %}"><span class="glyphicon glyphicon-pencil"></span></a>
        {% endif %}
        <h2>{{ post.title }}
            {% if post.url %}
                <span class="url"><a href="{{post.url}}">{{post.url}}</a>
            {% endif %}
        </h2>
        <div class="date">
            {{ post.date_created }}
            {% include "reddit/subs_posted.html" with post=post %}
        </div>
        <p>{{ post.text|linebreaksbr }}</p>
    </div>
    <a class="btn btn-default" href="{% url 'add_comment_to_post' pk=post.pk%}">Add comment</a>
    {% if post.comment_count > 0 %}
        {% include "reddit/comment.html" with comments=post.children.all %}
    {% else %}
        <p>Be the first one to comment!</p>
    {% endif %}
{% endblock %}

add_comment.html

This is again fairly straightforward and very similar to post_edit.html

{% extends 'reddit/base.html' %}
{% block content %}
    <h1>New comment</h1>
    <form method="POST" class="post-form">{% csrf_token %}
        {{ form.as_p }}
        <button type="submit" class="save btn btn-default">Send</button>
    </form>
{% endblock %}

sub_detail.html

{% extends 'reddit/base.html' %}
{% block content %}
    <div class="heading">
        {% if sub.cover_image_url %} <img class="cover-image rounded thumbnail" src='{{sub.cover_image_url}}' /> {% endif %}
        <h1>{{sub.name}} </h1>
    </div>
    <b>Moderated by:
        {% for mod in sub.moderators.all %}
            <span class="moderator">{{mod.username}}</span>
        {% endfor %}
    </b>
    <div class="post-list">
        {% for post in sub.posts.all %}
            <div class="post">
                <h2>
                    <a href="{% url 'post_detail' pk=post.pk %}">{{ post.title }}</a>
                    {% if post.url %}
                        <span class="url"><a href="{{post.url}}">{{post.url}}</a>
                    {% endif %}
                </h2>
                <div class="date">
                    {{ post.date_created }}
                    {% include "reddit/subs_posted.html" with post=post %}
                </div>
                {% if post.url %}
                    <a href="{{post.url}}">{{post.url}}</a>
                {% endif %}
                <p>{{ post.text|linebreaksbr }}</p>
                {% if  post.comment_count > 0 %}
                    <p><a href="{% url 'post_detail' pk=post.pk %}">{{ post.comment_count }} Comments</a></p>
                {% endif %}
            </div>
        {% endfor %}
    </div>
{% endblock %}

There’s not a lot that’s special about this template, except that we include a few styles from bootstrap for the cover_image_url of the subreddit (line 6).

CSS File

You should take a shot at styling your Reddit clone yourself! Create a file called reddit.css inside reddit/static/css/reddit.css.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here’s what we did:

body {
    padding-left: 15px;
    background-color: #f9fbff;
}
h1 a {
    color: #FCA205;
    font-family: 'Helvetica';
}
.page-header {
    background-color: #0072b7;
    margin-top: 0;
    padding: 20px 20px 20px 40px;
}
.page-header h1, .page-header h1 a, .page-header h1 a:visited, .page-header h1 a:active {
    color: #ffffff;
    font-size: 36pt;
    text-decoration: none;
}
.content {
    margin-left: 40px;
}
h1, h2, h3, h4 {
    font-family: 'Helvetica';
}
.date {
    color: #828282;
}
.save {
    float: right;
}
.post-form textarea, .post-form input {
    width: 100%;
}
.top-menu, .top-menu:hover, .top-menu:visited {
    color: #ffffff;
    float: right;
    font-size: 14pt;
    margin-right: 20px;
}
.post {
    padding-bottom: 40px;
}
.post-list .post{
    padding-bottom: 0px;
    padding-left: 40px;
    position: relative;
    border-width: 1px;
    border-style: solid;
    border-color: rgb(218, 221, 222);
}
.post h1 a, .post h1 a:visited {
    color: #000000;
}
.url {
    font-size: .4em;
}
.comment{
    margin: 20px;
}
.logout {
    color: #fff;
}
.heading{
    clear:both;
}
.heading h1{
    display: inline-block;
    margin-left: 20px;
}
.cover-image {
    width: 300px;
    max-width: 100%;
    display: inline-block;
}

Step 7: Add the admin

As you already know from past projects and tutorials, let’s register the models with the admin.

Create an admin.py inside reddit/admin.py and paste in the following.

from django.contrib import admin
from .models import *
admin.site.register(Post)
admin.site.register(SubReddit)
admin.site.register(Comment)
admin.site.register(UserVote)
admin.site.register(SubRedditPost)

Step 8: Testing The Site Thus Far

Let’s test the site so far!

First, we have to go into the admin using our superuser account and create a subreddit. Click on Reddit → Sub reddits → +Add.

Adding a SubReddit

Fill in the details and hit save.

Now let’s run the server with python manage.py runserver.

Once you log in, your site should look something like:

Feel free to play around! You should be able to create and edit posts, create comments, etc. You won’t actually be able to see the comments you create yet, since we haven’t yet hooked up the comment_count updating logic, and our post_detail.html template explicitly checks that comment_count > 0.

Our sample homepacge

Our sample subreddit

Part 1 Completed!

Congratulations. You have the bare bones version of the models, views, and templates working. In the next part, we’ll look at adding signals for automated counts as well as the voting and searching logic.


© 2016-2022. All rights reserved.