Welcome back to part 4, where we will be creating a second route in our project and adding delete functionality.
This is part 1 of the following series:
- Part 0 π Giggle – Creating a Joke Search Engine from Scratch with ChatGPT (0/6)
- Part 1 π Giggle GPT Joke Search Engine – Basic Setup (1/6)
- Part 2 π Giggle GPT Joke Search Engine – Implementing the ChatGPT API (2/6)
- Part 3 π Giggle GPT Joke Search Engine – Django ORM & Data Saving (3/6)
- Part 4 π Giggle GPT Joke Search Engine – Separate History Route & Delete Items (4/6)
- Part 5 π Giggle GPT Joke Search Engine – Implementing Pagination (5/6)
- Part 6 π Giggle GPT Joke Search Engine – ChatGPT Productivity & LLM Security (6/6)
π Full 6-video course with downloadable PDF certificates: Giggle – Creating a Joke Search Engine from Scratch with ChatGPT
As always, make sure your virtual environment is loaded before we get started.
Linux(bash):
$ source venv/Scripts/activate
Windows:
$ venv/Scripts/activate (uses the.bat file)
You should see (venv
) in your terminal before or on the current line.
Adding a Second Route
Now let’s add a second route to our application. Go to giggle/urls.py
and add a second route for a path we will name history to the urlpatterns
list.
urlpatterns = [ path('', views.giggle_search, name='giggle_search'), path('history/', views.giggle_history, name='giggle_history'), ]
Below the existing path, we added a path for www.example.com/history
. It will catch any traffic directed here and refer it to a view function in our views.py
file called giggle_history
, which we have not created yet.
We also defined a namespace of giggle_history
we can again use for making links later on. Let’s first go to views.py
to create the views.giggle_history
function we refer to as it does not exist yet.
Scroll down a bunch past your current existing giggle_search
function, as we will be adding a similar new function below after it ends.
def giggle_history(request): jokes = Joke.objects.all().order_by('-created_at') return render(request, './giggle/giggle_history.html', context={'jokes': jokes})
We define the view function we just linked to in our urls.py
. This is a view function so it receives the request
object just like our other view function did above.
We get the jokes just like we did in the previous tutorial part and order them by creation date in reverse. We return with a render
function, but this time we render a different template called giggle_history.html
.
We pass in the context as always, except this time the context object is very simple so we define it in line and pass it in at the same time, which is an alternative to defining it first and passing it in separately.
New Template
There is of course no such template by the name of giggle_history.html
, so let’s go ahead and create one now, making sure to put the file inside of our giggle/templates/giggle
folder and naming it giggle_history.html
. It will be located in the same folder as our base.html
and giggle_search.html
files.
Inside, we’ll code up the following template:
{% extends "./base.html" %} {% load static %} {% block content %} <header> <a href="{% url 'giggle_search' %}"> <img class="img-fluid" src="{% static 'giggle.png' %}" alt="Giggle logo" /> </a> </header> <main> <div class="history"> {% if jokes %} {% for joke in jokes %} <div class="joke"> <h3>{{ joke.query }}</h3> <p>{{ joke.response }}</p> </div> {% endfor %} {% endif %} </div> </main> {% endblock %}
We once again extend the ./base.html
template, thus using the same base boilerplate and loading the same base CSS as the giggle_search
template.
This ensures we don’t have to keep typing the same boilerplate and also makes sure that our pages uniformly have access to the same base. Now we load the giggle logo just like the other page. You might note that this header element is identical and thus duplicate with the giggle_search
page and you’d be right, we’ll fix this in a bit.
Then we have a main element with a div we gave a class of history.
We simply first test if there are jokes passed into the template at all, and if there are we create a div
with a class of joke for each joke inside the jokes object.
After that, we merely need to make sure to close everything in the correct order and this template will now work correctly whether 0, 2, or 100 jokes are passed in.
Let’s check out our /history
route. Don’t forget to save everything and run your development server.
python manage.py runserver
Open your page and then add /history
in the address bar for:
http://127.0.0.1:8000/history/ (you might be on a different port, use your own address)
Tadaa! But you’ll notice the layout is a bit messed up. Let’s fix that next. We’ve been a little bit lazy on the css so far so let us tidy up a bit.
CSS fixes
First go back to giggle_search.html
, our older template, and find the opening tag for the form which looks as follows:
<form class="input-group mb-5" action="/" method="post">
We will remove the 'mb-5'
class and add a class we will name 'query-input'
:
<form class="input-group query-input" action="/" method="post">
Everything else in this file will remain untouched, we merely removed one class and added a new one to the existing form tag.
Now go to your style.css
file inside the static
folder. Go ahead and remove everything in the file (there are only 5 small selectors in there anyway).
Your page will look a bit messy now. One thing to remember again while running our Django development server is we sometimes will need a hard refresh to see CSS changes because the browser is using the previous CSS file without your changes from its cache.
I’ve said this multiple times before but it’s easy to forget still and get stuck hunting for a nonexisting mistake. CTRL+F5
for Chrome will do a hard refresh.
Now let’s get back to our just emptied styles.css
file and add some basic styles. We’ll keep it very simple and only briefly describe it, as it’s not the focus of this tutorial.
html, body { min-height: 100vh; background-color: whitesmoke; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; }
We set the html
and body
element (simplistically speaking the main container), to a minimum height of 100viewport
height. We set a background-color
of whitesmoke
, which is slightly off-white, and use box-sizing: border-box
to make CSS calculate the size of elements in a more predictable way.
(If you’re interested in more details on this, check out the MDN docs: https://developer.mozilla.org/en-US/docs/Web/CSS/box-sizing)
The last three declarations create a flex container, set it to column, and then center it horizontally.
Now add a couple more class selectors below to target the classes we created in our HTML.
.container { max-width: 500px; padding: 5em 0; } .query-input { margin-bottom: 1.5em; } .history { margin-top: 2em; } .joke:first-letter { text-transform: uppercase; }
We select the main container and give it a max width of 500px and a bit of top and bottom padding, but 0 for left and right.
The query-input
gets a bit of bottom margin and the history div gets some margin up top. Finally, we select the first-letter of any div
with a class of 'joke'
and capitalize it once again, so we have the capitalized queries.
Now if we load the history page we can see it looks much better already, notice that if we click the giggle logo it takes us back to the main page where we can search.
Let’s get rid of the history on the main page. Go to giggle_search.html
and locate the {% if old_jokes %}
conditional template syntax block.
{% if old_jokes %} {% for joke in old_jokes %} <div class="joke_old"> <h3>{{ joke.query }}</h3> <p>{{ joke.response }}</p> </div> {% endfor %} {% endif %}
Remove this whole part, but nothing more, and replace it with:
<a href="{% url 'giggle_history' %}" class="history-link"><small>See history</small></a>
This is a temporary link for now, again using the template syntax to call ‘url
‘ and then providing the namespace of the route we want to link to in brackets.
Now load your website and you can switch back and forth between your pages using the logo and the history link you just created.
Keeping the HTML DRY (Don’t Repeat Yourself)
We’re getting along pretty well. but notice we use exactly the same logo image which is a clickable link in both of our templates.
If we ever want to change this later, we will have to change the code in multiple places. For a project this small and simple that may not be much of an issue, but let’s make sure even our HTML code is as dry as possible. Such is the power of templating.
Make a new empty file called logo.html
inside the giggle/templates/giggle
folder, where all your other HTML files are also located. Inside this empty file we will first define our template block as follows, making sure to also declare ‘load static
‘ as we will be loading the logo image in here:
{% block logo %} {% load static %} {% endblock %}
Then in between, simply copy-pasty the head section from either your giggle_search
or giggle_history
template.
{% block logo %} {% load static %} <header> <a href="{% url 'giggle_search' %}"> <img class="img-fluid" src="{% static 'giggle.png' %}" alt="Giggle logo" /> </a> </header> {% endblock %}
Now we need to remove the header from both our giggle_history
and giggle_search
pages.
Go to giggle_history
and delete the {% load static %}
line up top (we will no longer load the logo inside this HTML file) and the entire header HTML element.
This leaves you with a giggle_history.htm
l of:
{% extends "./base.html" %} {% block content %} <main> <div class="history"> {% if jokes %} {% for joke in jokes %} <div class="joke"> <h3>{{ joke.query }}</h3> <p>{{ joke.response }}</p> </div> {% endfor %} {% endif %} </div> </main> {% endblock %}
Now head over to the giggle_search.html
page and again remove the {% load static %}
line and the entire header HTML element.
You should be left with:
{% extends "./base.html" %} {% block content %} <main> <form class="input-group query-input" action="/" method="post"> {% csrf_token %} <input type="text" name="query" class="form-control" placeholder="Input topic" maxlength="50" /> <button class="btn btn-outline-secondary" type="submit"> <span class="material-icons mt-2"> search </span> </button> </form> <div class="results"> {% if query %} <div class="joke"> <h3>{{ query }}</h3> <p>{{ response }}</p> </div> {% endif %} <a href="{% url 'giggle_history' %}" class="history-link"> <small>See history</small> </a> </div> </main> {% endblock %}
Now we will go all the way back to the root, our base.html
file in the giggle/templates/giggle
folder. Ignore everything and scroll down to the ‘body
‘ HTML tag, changing the following code:
... <body> <div class="container"> {% block content %}{% endblock %} </div> </body> ...
To this:
... <body> <div class="container"> {% include './logo.html' %} {% block content %}{% endblock %} </div> </body> ...
Now we simply include the logo.html
file on any page, and then insert whatever content block is needed below it.
It’s that simple and we have no more duplicate HTML. It may seem like a lot of effort to do this, but as projects become more and more complex it will actually save you a lot of time and headaches to not have the same code in multiple places. Your web application should still look identically the same.
Adding a Delete Icon
Now that’s all nice and well, but to be honest, the history page is kind of a mess.
All of the jokes get saved, but some of them are honestly no good, or we refreshed the page and got the same joke again, giving us a duplicate. We should probably give the user the ability to remove poor-quality jokes from the list and only keep the ones they like.
First, let’s go to the giggle_history.html
file (giggle/templates/giggle
) and add a trash can icon to each entry. We will use the material icons set to achieve this. Find the following line inside the joke class div
<h3>{{ joke.query }}</h3>
And add a span
below like follows:
<h3>{{ joke.query }}</h3> <span class="material-icons trash-icon">delete</span>
Adding the class of ‘material-icons
‘ in combination with the span
‘s content of ‘delete
‘ will tell material icons to change this into a trash can icon for us.
The second class name of 'trash-icon'
is one we added ourselves for our own use in just a moment.
If you load your /history
page right now the trash icons are taking up space in an awkward position between the query and the response. We will have to use some CSS here.
Go back to static/styles.css
and add the following to the bottom of the file.
.trash-icon { color: red; position: absolute; }
All of the trash can icons have this class so were selected and all turned red. Notice there is no longer any reserved space for the icons and they overlap the text because of the positioning of absolute.
Now let’s set their position to be 10px away from the top and 10px away from the right. Edit your CSS selector to:
.trash-icon { color: red; position: absolute; top: 10px; right: 10px; }
As you can see all the icons now moved 10px from the top and 10px from the right relative to the body element!
It looks like there’s only one because they are all in the same location, exactly where we specified. This is not what we wanted. We need to provide CSS with an anchor, a context if you will, from which to calculate these 10px distances to arrive at the place we want.
If we look into our giggle_history.html
, each span is wrapped inside the div
with a class of ‘joke
‘. We need to set this div
, of which there will be a separate one for every single joke and trashcan icon, to be the anchor from which this absolute positioning will be calculated.
Go to styles.css
and add the following.
.joke { position: relative; }
This targets the .joke
class and sets it as the relative anchor for the position: absolute trash-icon inside. Now you will see many trash icons again, in a much better location.
Go back to the styles.css
and edit your .trash-icon
selector once more, leaving the .joke
selector untouched like this:
.trash-icon { color: #AAA; position: absolute; top: 8px; left: -30px; cursor: pointer; } .joke { position: relative; }
So I’m actually going to calculate the position relative to the left side and give them a bit of a grey color so as not to draw too much attention. I also included a cursor:
pointer which changes the mouse pointer when hovering over the icon to make it clear that the user can click these.
Or at least they will be able to do so in the future.
Finally, we’re going to add a hover state to change the color slightly when the user hovers over the trash can icons. This makes it extra clear to the user that they can be clicked.
.trash-icon:hover { color: #888; }
Check out your page to see the changes, looks much better already!
Getting the ID of the item to delete
Now that we have delete buttons, how do we make them delete a specific item?
We’ll need to which item to delete first. As you may know, every item generally has a unique ID in a SQL database. You may have also noticed that we never bothered to define an ID in our model for the Joke
object. This is because Django adds these IDs for us automatically.
Go to your giggle_history.html
file and find the line with:
<p>{{ joke.response }}</p>
Then add an extra line below like this:
<p>{{ joke.response }}</p> <p>{{ joke.id }}</p>
Now reload your giggle history page in the browser and you’ll see that a unique ID shows up below every joke on our page. Django has been handling these unique IDs for us all along, we just have not been using them up till now.
Go ahead and delete the {{ joke.id }}
line again as we won’t be needing it, so you are left as you started with only:
<p>{{ joke.response }}</p>
Creating a New Path/Route to Handle Deletion
Now that we know we have an id for each joke we can go ahead and set up a new route to delete specific jokes. Go to the urls.py
file inside your giggle
folder (not the one in the project folder!).
We will add a third route to the urlpattern
list below our history/
route.
urlpatterns = [ path('', views.giggle_search, name='giggle_search'), path('history/', views.giggle_history, name='giggle_history'), path('delete/<int:pk>', views.delete_joke, name='delete_joke'), ]
As you can see we have a subroute of delete and then another subroute.
For example www.website.com/delete/15
or www.website.com/delete/3
.
The main part is that the number of the end can be any number and this route will still be triggered. The syntax with the angle brackets means that we will capture a variable there that can be different each time.
The type of variable we will capture is an int
, or integer, and the name we will give to this variable is pk
, short for primary key. This pk
will be the number of the joke we want to delete by visiting this route.
We defined a namespace of delete_joke
and refer to views.delete_joke
, which means we will go to views.py
next to actually create a delete_joke
function in there to handle the logic.
Inside your views.py
file change the first line from:
from django.shortcuts import render, redirect
to:
from django.shortcuts import render, redirect, get_object_or_404
Adding an important import we’ll talk about in a moment.
Now scroll down below both your giggle_search
and giggle_history
functions to declare a new function at the bottom of your file called delete_joke
.
def delete_joke(request, pk): joke_to_delete = get_object_or_404(Joke, pk=pk) joke_to_delete.delete() return redirect('giggle_history')
In the first line, notice we’re taking a second argument besides the request this time. This is the pk
captured in the urls.py
we just talked about a moment ago and thus will contain the number of the joke we want to delete.
First, we have to retrieve the joke from the database.
We get the joke_to_delete
using a Django helper function that will get the object from the database.
If no such object exists for the given criteria this function will return a 404 not found error, just in case a user types in ID numbers for fun that do not exist as jokes, a bit of quick error handling there.
The get_object_or_404
function takes the Django Model that we want to retrieve an instance of, and the primary key, or simply the id of the instance we are looking for. Again, Django will take care of the SQL for us.
Once we have found the joke item we want to delete, Django provides an easy .delete()
method for us, after which we throw a redirect using the ‘giggle_history
‘ namespace to redirect back to our history page.
Linking Up the Delete Icons
Now we’ll need to provide each trashcan icon in our HTML template with its own unique deletion link for its respective joke.
Head over to giggle_history.html
in your giggle/templates/giggle
folder. Wrap the material-icons span
inside an anchor tag as follows:
<a href="{% url 'delete_joke' pk=joke.id %}"> <span class="material-icons trash-icon">delete</span> </a>
First, we open the link as always using {% url %}
.
Then inside we provide the namespace we want to link to inside brackets as we have always done so far, in this case, ‘delete_joke
‘. After that, we get to declare the variable part of the path which is pk
, and we set it to the joke.id
value.
We saw in our earlier test that the IDs are already freely available inside our template and we can just reference them using joke.id
.
As this link code executes for every joke separately, each link will be uniquely generated with the correct id to delete that particular joke. Your giggle_history.html
should now look like this:
{% extends "./base.html" %} {% block content %} <main> <div class="history"> {% if jokes %} {% for joke in jokes %} <div class="joke"> <h3>{{ joke.query }}</h3> <a href="{% url 'delete_joke' pk=joke.id %}"> <span class="material-icons trash-icon">delete</span> </a> <p>{{ joke.response }}</p> </div> {% endfor %} {% endif %} </div> </main> {% endblock %}
If you now reload the history page on your server and hover over the trash can icons without clicking, you will see in the lower left corner of the browser that each trash can has a unique link.
If you click one you will now delete it and be automatically referred back to the history page but with that particular joke removed. Go ahead and delete a couple of the bad jokes!
On a more advanced webpage, you would add more extensive safety checks, such as first checking if the user is logged in at all when trying to access this route, and then checking if this object belongs to them and their specific account, and only deleting if they have the required permission or ownership for this particular object. Otherwise anyone could paste in random IDs in their browser’s URL bar and delete objects from your database.
This is perhaps something for a next intermediate tutorial series if you guys like it, let me know in the comments, or send Chris an email to let him know you want me to make some more for you.
For now, we’ll keep this page in our project free for all users to edit and delete jokes without any ownership or user accounts.
You’ve done very well so far, I’ll see you in the next part where we’ll look at pagination, or breaking up a very large page of results into multiple smaller pages. I hope to see you there soon!
- Part 0 π Giggle – Creating a Joke Search Engine from Scratch with ChatGPT (0/6)
- Part 1 π Giggle GPT Joke Search Engine – Basic Setup (1/6)
- Part 2 π Giggle GPT Joke Search Engine – Implementing the ChatGPT API (2/6)
- Part 3 π Giggle GPT Joke Search Engine – Django ORM & Data Saving (3/6)
- Part 4 π Giggle GPT Joke Search Engine – Separate History Route & Delete Items (4/6)
- Part 5 π Giggle GPT Joke Search Engine – Implementing Pagination (5/6)
- Part 6 π Giggle GPT Joke Search Engine – ChatGPT Productivity & LLM Security (6/6)