Chapter 13: Articles App
Course Contents
- Changelog
- Foreword
- Introduction
- Chapter 1: Initial Set Up
- Chapter 2: Hello, World Website
- Chapter 3: Personal Website
- Chapter 4: Company Website
- Chapter 5: Message Board Website
- Chapter 6: Blog Website
- Chapter 7: Forms
- Chapter 8: User Accounts
- Chapter 9: Newspaper Project
- Chapter 10: User Authentication
- Chapter 11: Bootstrap
- Chapter 12: Password Change and Reset
- Chapter 13: Articles App
- Chapter 14: Permissions and Authorization
- Chapter 15: Comments
- Chapter 16: Deployment
- Chapter 17: Conclusion
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 amodels.py
fileget_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.
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.
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.
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> ·
<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/
.
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> ·
<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/
.
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.
You'll be redirected to the delete page if you click the "Delete" link.
Press the scary red button for "Confirm." You'll be redirected to the articles page, which now has only 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.
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.
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.