Django for Beginners /

Chapter 13: Articles App

Course Contents

It's time to build out our Newspaper app. We will have an articles page where journalists can post articles, set up permissions so only the author of an article can edit or delete it, and finally add the ability for other users to write comments on each article.

Articles App

To start, create an articles app and define the database models. There are no hard and fast rules around what to name your apps except that you can't use the name of a built-in app. If you look at the INSTALLED_APPS section of django_project/settings.py, you can see which app names are off-limits:

  • admin
  • auth
  • contenttypes
  • sessions
  • messages
  • staticfiles

A general rule of thumb is to use the plural of an app name: posts, payments, users, etc. One exception would be when doing so is obviously wrong, such as blogs. In this case, using the singular blog makes more sense.

Start by creating our new articles app.

(.venv) $ python manage.py startapp articles

Then add it to our INSTALLED_APPS and update the time zone, TIME_ZONE, lower down in the settings since we'll be timestamping our articles. You can find your time zone in this Wikipedia list. For example, I live in Boston, MA, in the Eastern time zone of the United States; therefore, my entry is America/New_York.

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 3rd Party
    "crispy_forms",
    "crispy_bootstrap5",
    # Local
    "accounts",
    "pages",
    "articles",  # new
]

TIME_ZONE = "America/New_York"  # new

Next, we define our database model, which contains four fields: title, body, date, and author. We're letting Django automatically set the time and date based on our TIME_ZONE setting. For the author field, we want to reference our custom user model "accounts.CustomUser" which we set in the django_project/settings.py file as AUTH_USER_MODEL. We will also implement the best practice of defining a get_absolute_url and a __str__ method for viewing the model in our admin interface.

# articles/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse


class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    date = models.DateTimeField(auto_now_add=True)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("article_detail", kwargs={"pk": self.pk})

There are two ways to refer to a custom user model: AUTH_USER_MODEL and get_user_model. As general advice:

  • AUTH_USER_MODEL makes sense for references within a models.py file
  • get_user_model() is recommended everywhere else, such as views, tests, etc.

Since we have a new app and model, it's time to make a new migration file and apply it to the database.

(.venv) $ python manage.py makemigrations articles
Migrations for 'articles':
  articles/migrations/0001_initial.py
    - Create model Article
(.venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: accounts, admin, articles, auth, contenttypes, sessions
Running migrations:
  Applying articles.0001_initial... OK

At this point, I like to jump into the admin to play around with the model before building out the URLs/views/templates needed to display the data on the website. But first, we need to update articles/admin.py so our new app is displayed.

# articles/admin.py
from django.contrib import admin
from .models import Article

admin.site.register(Article)

Now, we start the server.

(.venv) $ python manage.py runserver

Navigate to the admin at http://127.0.0.1:8000/admin/ and log in.

Admin Page

If you click "+ Add" next to "Articles" at the top of the page, we can enter some sample data. You'll likely have three users available: your superuser, testuser, and testuser2 accounts. Create new articles using your superuser account as the author. I've added three new articles, as you can see on the updated Articles page.

Admin Three Articles

But wouldn't it be nice to see a little more information in the admin about each article? We can quickly do that by updating articles/admin.py with list_display.

# articles/admin.py
from django.contrib import admin
from .models import Article


class ArticleAdmin(admin.ModelAdmin):
    list_display = [
        "title",
        "body",
        "author",
    ]


admin.site.register(Article, ArticleAdmin)

We've extended ModelAdmin, a class that represents a model in the admin interface, and specified our fields to list with list_display. At the bottom of the file we registered ArticleAdmin along with the Article model we imported at the top. There are many customizations available in the Django admin, so the official docs are worth a close read.

Admin Three Articles with Description

If you click on an individual article, you will see that the title, body, and author are displayed but not the date, even though we defined a date field in our model. That's because the date was automatically added by Django for us and, therefore, can't be changed in the admin. We could make the date editable--in more complex apps, it's common to have both a created_at and updated_at attribute--but to keep things simple, we'll have the date be set upon creation by Django for us for now. Even though date is not displayed here, we can still access it in our templates for display on web pages.

URLs and Views

The next step is to configure our URLs and views. Let's have our articles appear at articles/. Add a URL pattern for articles in our django_project/urls.py file.

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("accounts.urls")),
    path("accounts/", include("django.contrib.auth.urls")),
    path("articles/", include("articles.urls")),  # new
    path("", include("pages.urls")),
]

Next, we create a new articles/urls.py file in the text editor and populate it with our routes. Let's start with the page to list all articles at articles/, which will use the view ArticleListView.

# articles/urls.py
from django.urls import path

from .views import ArticleListView

urlpatterns = [
    path("", ArticleListView.as_view(), name="article_list"),
]

Now, create our view using the built-in generic ListView from Django. The only two attributes we need to specify are the model Article and our template name, which will be article_list.html.

# articles/views.py
from django.views.generic import ListView

from .models import Article


class ArticleListView(ListView):
    model = Article
    template_name = "article_list.html"

The last step is to create a new template file in the text editor called templates/article_list.html. Bootstrap has a built-in component called Cards that we can customize for our individual articles. Recall that ListView returns an object with <model_name>_list that we can iterate using a for loop.

We display each article's title, body, author, and date. We can even provide links to the "detail", "edit", and "delete" pages that we haven't built yet.

<!-- templates/article_list.html -->
{% extends "base.html" %}

{% block title %}Articles{% endblock title %}

{% block content %}
{% for article in article_list %}
<div class="card">
  <div class="card-header">
    <span class="fw-bold">
      <a href="#">{{ article.title }}</a>
    </span> &middot;
    <span class="text-muted">by {{ article.author }} |
      {{ article.date }}</span>
  </div>
  <div class="card-body">
    {{ article.body }}
  </div>
  <div class="card-footer text-center text-muted">
    <a href="#">Edit</a> <a href="#">Delete</a>
  </div>
</div>
<br />
{% endfor %}
{% endblock content %}

Start the server again and check out our page at http://127.0.0.1:8000/articles/.

Articles Page

Not bad, eh? If we wanted to get fancy, we could create a custom template filter so that the date outputted is shown in seconds, minutes, or days. This can be done with some if/else logic and Django's date options, but we won't implement it here.

Detail/Edit/Delete

The next step is to add detail, edit, and delete options for the articles. That means new URLs, views, and templates. Let's start with the URLs. The Django ORM automatically adds a primary key to each database entry, meaning that the first article has a pk value of 1, the second of 2, and so on. We can use this to craft our URL paths.

For our detail page, we want the route to be at articles/<int:pk>. The int here is a path converter and essentially tells Django that we want this value to be treated as an integer and not another data type like a string. Therefore, the URL route for the first article will be articles/1/. Since we are in the articles app, all URL routes will be prefixed with articles/ because we set that in django_project/urls.py. We only need to add the <int:pk> part here.

Next up are the edit and delete routes that will also use the primary key. They will be at the URL routes articles/1/edit/ and articles/1/delete/ with the primary key of 1. Here is how the updated articles/urls.py file should look.

# articles/urls.py
from django.urls import path

from .views import (
    ArticleListView,
    ArticleDetailView,  # new
    ArticleUpdateView,  # new
    ArticleDeleteView,  # new
)

urlpatterns = [
    path("<int:pk>/", ArticleDetailView.as_view(),
        name="article_detail"),  # new
    path("<int:pk>/edit/", ArticleUpdateView.as_view(),
        name="article_edit"),  # new
    path("<int:pk>/delete/", ArticleDeleteView.as_view(),
        name="article_delete"),  # new
    path("", ArticleListView.as_view(),
        name="article_list"),
]

We will use Django's generic class-based views for DetailView, UpdateView, and DeleteView. The detail view only requires listing the model and template name. For the update/edit view, we also add the specific attributes--title and body--that can be changed. And for the delete view, we must add a redirect for where to send the user after deleting the entry. That requires importing reverse_lazy and specifying the success_url along with a corresponding named URL.

# articles/views.py
from django.views.generic import ListView, DetailView  # new
from django.views.generic.edit import UpdateView, DeleteView  # new
from django.urls import reverse_lazy  # new
from .models import Article


class ArticleListView(ListView):
    model = Article
    template_name = "article_list.html"


class ArticleDetailView(DetailView):  # new
    model = Article
    template_name = "article_detail.html"


class ArticleUpdateView(UpdateView):  # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"


class ArticleDeleteView(DeleteView):  # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")

If you recall the acronym CRUD (Create-Read-Update-Delete), you'll see that we are implementing three of the four functionalities here. We'll add the fourth, for create, later in this chapter. Almost every website uses CRUD, and this pattern will quickly feel natural when using Django or any other web framework.

The URL paths and views are done, so the final step is to add templates. Create three new template files in your text editor:

  • templates/article_detail.html
  • templates/article_edit.html
  • templates/article_delete.html

We'll start with the details page, which displays the title, date, body, and author and links to edit and delete. It also links back to all articles. DetailView automatically names the context object either object or the lowercase model name. Recall that the Django templating language's url tag wants the URL name, and any arguments are passed in.

The name of our edit route is article_edit, and we need to use its primary key, article.pk. The delete route name is article_delete, and requires a primary key, article.pk. Our articles page is a ListView, so it does not require any additional arguments passed in.

<!-- templates/article_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="article-entry">
  <h2>{{ object.title }}</h2>
  <p>by {{ object.author }} | {{ object.date }}</p>
  <p>{{ object.body }}</p>
</div>
<div>
  <p><a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  </p>
  <p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}

For the edit and delete pages, we can use Bootstrap's button styling to make the edit button light blue and the delete button red.

<!-- templates/article_edit.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %}

{% block content %}
<h1>Edit</h1>
<form action="" method="post">{% csrf_token %}
  {{ form|crispy }}
  <button class="btn btn-info ms-2" type="submit">Update</button>
</form>
{% endblock content %}
<!-- templates/article_delete.html -->
{% extends "base.html" %}

{% block content %}
<h1>Delete</h1>
<form action="" method="post">{% csrf_token %}
  <p>Are you sure you want to delete "{{ article.title }}"?</p>
  <button class="btn btn-danger ms-2" type="submit">Confirm</button>
</form>
{% endblock content %}

As a final step, in article_list.html, we can now add URL routes for detail, edit, and delete pages to replace the existing <a href="#"> placeholders. We can use the get_absolute_url method defined in our model for the detail page. And we can use the url template tag, the URL name, and the pk of each article for the edit and delete links.

{title="Code",lang="html"}

<!-- templates/article_list.html -->
{% extends "base.html" %}

{% block title %}Articles{% endblock title %}

{% block content %}
{% for article in article_list %}
<div class="card">
  <div class="card-header">
    <span class="fw-bold">
      <!-- add link here! -->
      <a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
    </span> &middot;
    <span class="text-muted">by {{ article.author }} |
      {{ article.date }}</span>
  </div>
  <div class="card-body">
    {{ article.body }}
  </div>
  <div class="card-footer text-center text-muted">
    <!-- new links here! -->
    <a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  </div>
</div>
<br />
{% endfor %}
{% endblock content %}

Ok, we're ready to view our work. Start the server with python manage.py runserver and navigate to the articles list page at http://127.0.0.1:8000/articles/. Click the "Edit" link next to the first article, and you'll be redirected to http://127.0.0.1:8000/articles/1/edit/.

Edit Page

If you update the "Title" attribute by adding "(edited)" at the end and click "Update", you'll be redirected to the detail page, which shows the new change.

Detail Page

You'll be redirected to the delete page if you click the "Delete" link.

Delete Page

Press the scary red button for "Confirm." You'll be redirected to the articles page, which now has only two entries.

Articles Page Two Entries

Create Page

The final step is to create a page for new articles, which we can implement with Django's built-in CreateView. Our three steps are to create a view, URL, and template. This flow should feel familiar by now.

In the articles/views.py file, add CreateView to the imports at the top and create a new class called ArticleCreateView at the bottom of the file that specifies our model, template, and the fields available.

# articles/views.py
...
from django.views.generic.edit import (
  UpdateView, DeleteView, CreateView # new
)
...
class ArticleCreateView(CreateView):  # new
    model = Article
    template_name = "article_new.html"
    fields = (
        "title",
        "body",
        "author",
    )

Note that our fields attribute has author since we want to associate a new article with an author; however, once an article has been created, we do not want a user to be able to change the author, which is why ArticleUpdateView only has the attributes ['title', 'body',].

Now, update the articles/urls.py file with the new route for the view.

# articles/urls.py
from django.urls import path

from .views import (
    ArticleListView,
    ArticleDetailView,
    ArticleUpdateView,
    ArticleDeleteView,
    ArticleCreateView, # new
)

urlpatterns = [
    path("<int:pk>/",
        ArticleDetailView.as_view(), name="article_detail"),
    path("<int:pk>/edit/",
        ArticleUpdateView.as_view(), name="article_edit"),
    path("<int:pk>/delete/",
        ArticleDeleteView.as_view(), name="article_delete"),
    path("new/", ArticleCreateView.as_view(), name="article_new"),  # new
    path("", ArticleListView.as_view(), name="article_list"),
]

To complete the new create functionality, add a template named templates/article_new.html and update it with the following HTML code.

<!-- templates/article_new.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %}

{% block content %}
<h1>New article</h1>
<form action="" method="post">{% csrf_token %}
  {{ form|crispy }}
  <button class="btn btn-success ms-2" type="submit">Save</button>
</form>
{% endblock content %}

Additional Links

We should add the URL link for creating new articles to our navbar so that logged-in users can access it everywhere on the site.

<!-- templates/base.html -->
...
{% if user.is_authenticated %}
  <li><a href="{% url 'article_new' %}"
    class="nav-link px-2 link-dark">+ New</a></li>
...

Refreshing the webpage and clicking "+ New" will redirect to the create new article page.

New Article Page

One final link to add is to make the articles list page accessible from the home page. A user would need to know or guess that it is located at http://127.0.0.1:8000/articles/, but we can fix that by adding a button link to the templates/home.html file.

<!-- templates/home.html -->
{% extends "base.html" %}

{% block title %}Home{% endblock title %}

{% block content %}
{% if user.is_authenticated %}
Hi {{ user.username }}! You are {{ user.age }} years old.
<form action="{% url 'logout' %}" method="post">
  {% csrf_token %}
  <button type="submit">Log Out</button>
</form>
<br/>
<p><a class="btn btn-primary" href="{% url 'article_list' %}" role="button">
  View all articles</a></p> <!-- new -->
{% else %}
<p>You are not logged in</p>
<a href="{% url 'login' %}">Log In</a> |
<a href="{% url 'signup' %}">Sign Up</a>
{% endif %}
{% endblock content %}

Refresh the homepage and the button will appear and work as intended.

Homepage with All Articles Link

If you need help to make sure your HTML file is accurate now, please refer to the official source code.

Git

We added quite a lot of new code in this chapter, so let's save it with Git before proceeding to the next chapter.

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "newspaper app"
(.venv) $ git push origin main

Conclusion

We have now created a dedicated articles app with CRUD functionality. Articles can be created, read, updated, deleted, and even viewed as an entire list. But there are no permissions or authorizations yet, which means anyone can do anything! If logged-out users know the correct URLs, they can edit an existing article or delete one, even one that's not theirs! In the next chapter, we will add permissions and authorizations to our project to fix this.