image: http://djangopony.com/
.. rst-class:: large
Wherein we build a simple blogging app.
Django comes with:
.. rst-class:: build
.. rst-class:: build
- Persistence via the Django ORM
- CRUD content editing via the automatic Django Admin
- URL Mapping via urlpatterns
- Templating via the Django Template Language
- Caching with levels of configurability
- Internationalization via i18n hooks
- Form rendering and handling
- User authentication and authorization
Pretty much everything you need to make a solid website quickly
.. nextslide:: What Sets it Apart?
Lots of frameworks offer some of these features, if not all.
.. rst-class:: build
What is Django's killer feature
.. rst-class:: centered
The Django Admin
.. nextslide:: The Django Admin
Works in concert with the Django ORM to provide automatic CRUD functionality
.. rst-class:: build
You write the models, it provides the UI
You've seen this in action. Pretty neat, eh?
.. nextslide:: The Pareto Principle
The Django Admin is a great example of the Pareto Priciple, a.k.a. the 80/20 rule:
.. rst-class:: build
.. rst-class:: centered
80% of the problems can be solved by 20% of the effort
The converse also holds true:
.. rst-class:: centered
Fixing the last 20% of the problems will take the remaining 80% of the effort.
.. nextslide:: Other Django Advantages
.. ifnotslides::
**Other Django Advantages**
Clearly the most popular full-stack Python web framework at this time
.. rst-class:: build
Popularity translates into:
.. rst-class:: build
- Active, present community
- Plethora of good examples to be found online
- Rich ecosystem of apps (encapsulated add-on functionality)
.. rst-class:: centered
Jobs
.. nextslide:: Active Development
Django releases in the last 12+ months (a short list):
.. rst-class:: build
.. rst-class:: build
- 1.9 (December 2015)
- 1.8.7 (November 2015)
- 1.7.11 (November 2015)
- 1.8.5 (October 2015)
- 1.7.10 (August 2015)
- 1.8.3 (July 2015)
- 1.8 (April 2015)
- 1.7.7 (March 2015)
- 1.7.4 (January 2014)
Django 1.8 is the second Long Term Support version, with a guaranteed support period of three years.
.. nextslide:: Great Documentation
Thorough, readable, and discoverable.
.. rst-class:: build
Led the way to better documentation for all Python
Read The Docs - built in connection with Django, sponsored by the Django Software Foundation.
Write documentation as part of your python package.
Render new versions of that documentation for every commit.
.. rst-class:: centered
this is awesome
For your homework this week, you created a Post model to serve as the heart
of our blogging app.
.. rst-class:: build
You also took some time to get familiar with the basic workings of the Django ORM.
You made a minor modification to our model class and wrote a test for it.
And you installed the Django Admin site and added your app to it.
One of the most common features in a blog is the ability to categorize posts.
.. rst-class:: build
Let's add this feature to our blog!
To do so, we'll be adding a new model, and making some changes to existing code.
.. rst-class:: build
This means that we'll need to change our database schema.
.. nextslide:: Changing a Database
You've seen how to add new tables to a database using the migrate command.
.. rst-class:: build
And you've created your first migration in setting up the Post model.
This is an example of altering the database schema using Python code.
Starting in Django 1.7, this ability is available built-in to Django.
Before verson 1.7 it was available in an add-on called South.
.. nextslide:: Adding a Model
We want to add a new model to represent the categories our blog posts might fall into.
.. rst-class:: build
This model will need to have:
.. rst-class:: build
- a name for the category
- a longer description
- a relationship to the Post model
# in models.py
class Category(models.Model):
name = models.CharField(max_length=128)
description = models.TextField(blank=True)
posts = models.ManyToManyField(Post, blank=True,
related_name='categories').. nextslide:: Strange Relationships
In our Post model, we used a ForeignKeyField field to match an author
to her posts.
.. rst-class:: build
This models the situation in which a single author can have many posts, while each post has only one author.
We call this a Many to One relationship.
But any given Post might belong in more than one Category.
And it would be a waste to allow only one Post for each Category.
Enter the ManyToManyField
.. nextslide:: Add a Migration
To get these changes set up, we now add a new migration.
.. rst-class:: build
We use the makemigrations management command to do so:
(djangoenv)$ ./manage.py makemigrations
Migrations for 'myblog':
0002_category.py:
- Create model Category.. nextslide:: Apply A Migration
Once the migration has been created, we can apply it with the migrate
management command.
.. rst-class:: build
(djangoenv)$ ./manage.py migrate
Operations to perform:
Apply all migrations: sessions, contenttypes, admin, myblog, auth
Running migrations:
Rendering model states... DONE
Applying myblog.0002_category... OKYou can even look at the migration file you just applied,
myblog/migrations/0002_category.py to see what happened.
.. nextslide:: Make Categories Look Nice
Let's make Category object look nice the same way we did with Post.
Start with a test:
.. rst-class:: build
add this to tests.py:
# another import
from myblog.models import Category
# and the test case and test
class CategoryTestCase(TestCase):
def test_string_representation(self):
expected = "A Category"
c1 = Category(name=expected)
actual = str(c1)
self.assertEqual(expected, actual).. nextslide:: Make it Pass
When you run your tests, you now have two, and one is failing because the
Category object doesn't look right.
.. rst-class:: build
(djangoenv)$ ./manage.py test myblog
Creating test database for alias 'default'...
...
Ran 2 tests in 0.011s
FAILED (failures=1)Do you remember how you made that change for a Post?
class Category(models.Model):
#...
def __str__(self):
return self.name.. nextslide:: Admin for Categories
Adding our new model to the Django admin is equally simple.
.. rst-class:: build
Simply add the following line to myblog/admin.py
# a new import
from myblog.models import Category
# and a new admin registration
admin.site.register(Category).. nextslide:: Test It Out
Fire up the Django development server and see what you have in the admin:
(djangoenv)$ ./manage.py runserver
Validating models...
...
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C... rst-class:: build
Point your browser at http://localhost:8000/admin/, log in and play.
Add a few categories, put some posts in them. Visit your posts, add new ones and then categorize them.
We've completed a data model for our application.
And thanks to Django's easy-to-use admin, we have a reasonable CRUD application where we can manage blog posts and the categories we put them in.
When we return, we'll put a public face on our new creation.
If you've fallen behind, the app as it stands now is in our class resources as
mysite_stage_1
.. rst-class:: left
Point your browser at http://localhost:8000/
.. rst-class:: build left
What do you see?
Why?
We need to add some public pages for our blog.
In Django, the code that builds a page that you can see is called a view.
A view can be defined as a callable that takes a request and returns a response.
.. rst-class:: build
This should sound pretty familiar to you.
Classically, Django views were functions.
Version 1.3 added support for Class-based Views (a class with a
__call__ method is a callable)
.. nextslide:: A Basic View
Let's add a really simple view to our app.
.. rst-class:: build
It will be a stub for our public UI. Add this to views.py in
myblog
from django.http import HttpResponse, HttpResponseRedirect, Http404
def stub_view(request, *args, **kwargs):
body = "Stub View\n\n"
if args:
body += "Args:\n"
body += "\n".join(["\t%s" % a for a in args])
if kwargs:
body += "Kwargs:\n"
body += "\n".join(["\t%s: %s" % i for i in kwargs.items()])
return HttpResponse(body, content_type="text/plain").. nextslide:: Hooking It Up
In your homework tutorial, you learned about Django urlconfs
.. rst-class:: build
We used our project urlconf to hook the Django admin into our project.
We want to do the same thing for our new app.
In general, an app that serves any sort of views should contain its own urlconf.
The project urlconf should mainly include these where possible.
.. nextslide:: Adding A Urlconf
Create a new file urls.py inside the myblog app package.
.. rst-class:: build
Open it in your editor and add the following code:
from django.conf.urls import url
from myblog.views import stub_view
urlpatterns = [
url(r'^$',
stub_view,
name="blog_index"),
].. nextslide:: Include Blog Urls
In order for our new urls to load, we'll need to include them in our project urlconf
.. rst-class:: build
Open urls.py from the mysite project package and add this:
# add this new import
from django.conf.urls import include
# then modify urlpatterns as follows:
urlpatterns = [
url(r'^', include('myblog.urls')), #<- add this
#... other included urls
]Try reloading http://localhost:8000/
You should see some output now.
A project is defined by the urls a user can visit.
.. rst-class:: build
What should our users be able to see when they visit our blog?
.. rst-class:: build
- A list view that shows blog posts, most recent first.
- An individual post view, showing a single post (a permalink).
Let's add urls for each of these.
For now, we'll use the stub view we've created so we can concentrate on the url routing.
.. nextslide:: Our URLs
We've already got a good url for the list page: blog_index at '/'
.. rst-class:: build
For the view of a single post, we'll need to capture the id of the post.
Add this to urlpatterns in myblog/urls.py:
url(r'^posts/(\d+)/$',
stub_view,
name="blog_detail"),(\d+) captures one or more digits as the post_id.
Load http://localhost:8000/posts/1234/ and see what you get.
.. nextslide:: A Word on Capture in URLs
When you load the above url, you should see 1234 listed as an arg
.. rst-class:: build
Try changing the route like so:
r'^posts/(?P<post_id>\d+)/$'Reload the same url.
Notice the change.
What's going on there?
.. nextslide:: Regular Expression URLS
Like Pyramid, Django uses Python regular expressions to build routes.
.. rst-class:: build
Unlike Pyramid, Django requires regular expressions to capture segments in a route.
When we built our WSGI book app, we used this same appraoch.
There we learned about regular expression capture groups. We just changed an unnamed capture group to a named one.
How you declare a capture group in your url pattern regexp influences how it will be passed to the view callable.
.. nextslide:: Full Urlconf
from django.conf.urls import url
from myblog.views import stub_view
urlpatterns = [
url(r'^$',
stub_view,
name="blog_index"),
url(r'^posts/(?P<post_id>\d+)/$',
stub_view,
name="blog_detail"),
].. nextslide:: Testing Views
Before we begin writing real views, we need to add some tests for the views we are about to create.
.. rst-class:: build
We'll need tests for a list view and a detail view
add the following imports at the top of myblog/tests.py:
import datetime
from django.utils.timezone import utc.. nextslide:: Add a Test Case
class FrontEndTestCase(TestCase):
"""test views provided in the front-end"""
fixtures = ['myblog_test_fixture.json', ]
def setUp(self):
self.now = datetime.datetime.utcnow().replace(tzinfo=utc)
self.timedelta = datetime.timedelta(15)
author = User.objects.get(pk=1)
for count in range(1, 11):
post = Post(title="Post %d Title" % count,
text="foo",
author=author)
if count < 6:
# publish the first five posts
pubdate = self.now - self.timedelta * count
post.published_date = pubdate
post.save()We'd like our list view to show our posts.
.. rst-class:: build
But in this blog, we have the ability to publish posts.
Unpublished posts should not be seen in the front-end views.
We set up our tests to have 5 published, and 5 unpublished posts
Let's add a test to demonstrate that the right ones show up.
.. nextslide:: Testing the List View
Class FrontEndTestCase(TestCase): # already here
# ...
def test_list_only_published(self):
resp = self.client.get('/')
# the content of the rendered response is always a bytestring
resp_text = resp.content.decode(resp.charset)
self.assertTrue("Recent Posts" in resp_text)
for count in range(1, 11):
title = "Post %d Title" % count
if count < 6:
self.assertContains(resp, title, count=1)
else:
self.assertNotContains(resp, title).. rst-class:: build
We test first to ensure that each published post is visible in our view.
Note that we also test to ensure that the unpublished posts are not visible.
.. nextslide:: Run Your Tests
(djangoenv)$ ./manage.py test myblog
Creating test database for alias 'default'...
.F.
======================================================================
FAIL: test_list_only_published (myblog.tests.FrontEndTestCase)
...
Ran 3 tests in 0.024s
FAILED (failures=1)
Destroying test database for alias 'default'..... nextslide:: Now Fix That Test!
Add the view for listing blog posts to views.py.
# add these imports
from django.template import RequestContext, loader
from myblog.models import Post
# and this view
def list_view(request):
published = Post.objects.exclude(published_date__exact=None)
posts = published.order_by('-published_date')
template = loader.get_template('list.html')
context = RequestContext(request, {
'posts': posts,
})
body = template.render(context)
return HttpResponse(body, content_type="text/html").. nextslide:: Getting Posts
published = Post.objects.exclude(published_date__exact=None)
posts = published.order_by('-published_date').. rst-class:: build
We begin by using the QuerySet API to fetch all the posts that have
published_date set
Using the chaining nature of the API we order these posts by
published_date
Remember, at this point, no query has actually been issued to the database.
.. nextslide:: Getting a Template
template = loader.get_template('list.html').. rst-class:: build
Django uses configuration to determine how to find templates.
By default, Django looks in installed apps for a templates directory
It also provides a place to list specific directories.
Let's set that up in settings.py
.. nextslide:: Project Templates
Notice that settings.py already contains a BASE_DIR value which points
to the root of our project (where both the project and app packages are
located).
.. rst-class:: build
In that same file, you'll find a list bound to the symbol TEMPLATES.
That list contains one dict with an empty list at the key DIRS. Update
that empty list as shown here:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'mysite/templates')],
...
},
]This will ensure that Django will look in your mysite project folder
for a directory containing templates.
.. nextslide::
The mysite project folder does not contain a templates directory, add one.
.. rst-class:: build
Then, in that directory add a new file base.html and add the following:
<!DOCTYPE html>
<html>
<head>
<title>My Django Blog</title>
</head>
<body>
<div id="container">
<div id="content">
{% block content %}
[content will go here]
{% endblock %}
</div>
</div>
</body>
</html>Before we move on, a quick word about Django templates.
.. rst-class:: build
We've seen Jinja2 which was "inspired by Django's templating system".
Basically, you already know how to write Django templates.
Django templates do not allow any python expressions.
https://docs.djangoproject.com/en/1.9/ref/templates/builtins/
.. nextslide:: Blog Templates
Our view tries to load list.html.
.. rst-class:: build
This template is probably specific to the blog functionality of our site
It is common to keep shared templates in your project directory and specialized ones in app directories.
Add a templates directory to your myblog app, too.
In it, create a new file list.html and add this:
.. nextslide:: ``list.html``
{% extends "base.html" %}{% block content %}
<h1>Recent Posts</h1>
{% comment %} here is where the query happens {% endcomment %}
{% for post in posts %}
<div class="post">
<h2>{{ post }}</h2>
<p class="byline">
Posted by {{ post.author_name }} — {{ post.published_date }}
</p>
<div class="post-body">
{{ post.text }}
</div>
<ul class="categories">
{% for category in post.categories.all %}
<li>{{ category }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% endblock %}.. nextslide:: Template Context
context = RequestContext(request, {
'posts': posts,
})
body = template.render(context).. rst-class:: build
Like Jinja2, django templates are rendered by passing in a context
Django's RequestContext provides common bits, similar to the context provided automatically by Pyramid
We add our posts to that context so they can be used by the template.
.. nextslide:: Return a Response
return HttpResponse(body, content_type="text/html").. rst-class:: build
Finally, we build an HttpResponse and return it.
This is, fundamentally, no different from the stub_view just above.
.. nextslide:: Fix URLs
We need to fix the url for our blog index page
.. rst-class:: build
Update urls.py in myblog:
# import the new view
from myblog.views import list_view
# and then update the urlconf
url(r'^$',
list_view, #<-- Change this value from stub_view
name="blog_index"),Then run your tests again:
(djangoenv)$ ./manage.py test myblog
...
Ran 3 tests in 0.033s
OK.. nextslide:: Common Patterns
This is a common pattern in Django views:
.. rst-class:: build
- get a template from the loader
- build a context, usually using a RequestContext
- render the template
- return an HttpResponse
.. rst-class:: build
So common in fact that Django provides a shortcut for us to use:
render(request, template[, ctx][, ctx_instance])
.. nextslide:: Shorten Our View
Let's replace most of our view with the render shortcut
from django.shortcuts import render # <- already there
# rewrite our view
def list_view(request):
published = Post.objects.exclude(published_date__exact=None)
posts = published.order_by('-published_date')
context = {'posts': posts}
return render(request, 'list.html', context).. rst-class:: build
Remember though, all we did manually before is still happening
We've got the front page for our application working great.
Next, we'll need to provide a view of a detail page for a single post.
Then we'll provide a way to log in and to navigate between the public part of our application and the admin behind it.
If you've fallen behind, the app as it stands now is in our class resources as
mysite_stage_2
Next, let's add a view function for the detail view of a post
.. rst-class:: build
It will need to get the id of the post to show as an argument
Like the list view, it should only show published posts
But unlike the list view, it will need to return something if an unpublished post is requested.
Let's start with the tests in views.py
.. nextslide:: Testing the Details
Add the following test to our FrontEndTestCase in myblog/tests.py:
def test_details_only_published(self):
for count in range(1, 11):
title = "Post %d Title" % count
post = Post.objects.get(title=title)
resp = self.client.get('/posts/%d/' % post.pk)
if count < 6:
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, title)
else:
self.assertEqual(resp.status_code, 404).. nextslide:: Run Your Tests
(djangoenv)$ ./manage.py test myblog
Creating test database for alias 'default'...
.F..
======================================================================
FAIL: test_details_only_published (myblog.tests.FrontEndTestCase)
...
Ran 4 tests in 0.043s
FAILED (failures=1)
Destroying test database for alias 'default'..... nextslide:: Let's Fix That Test
Now, add a new view to myblog/views.py:
def detail_view(request, post_id):
published = Post.objects.exclude(published_date__exact=None)
try:
post = published.get(pk=post_id)
except Post.DoesNotExist:
raise Http404
context = {'post': post}
return render(request, 'detail.html', context).. nextslide:: Missing Content
try:
post = published.get(pk=post_id)
except Post.DoesNotExist:
raise Http404One of the features of the Django ORM is that all models raise a DoesNotExist
exception if get returns nothing.
.. rst-class:: build
This exception is actually an attribute of the Model you look for.
There's also an ObjectDoesNotExist for when you don't know which model
you have.
We can use that fact to raise a Not Found exception.
Django will handle the rest for us.
.. nextslide:: Add the Template
We also need to add detail.html to myblog/templates:
{% extends "base.html" %}
{% block content %}
<a class="backlink" href="/">Home</a>
<h1>{{ post }}</h1>
<p class="byline">
Posted by {{ post.author_name }} — {{ post.published_date }}
</p>
<div class="post-body">
{{ post.text }}
</div>
<ul class="categories">
{% for category in post.categories.all %}
<li>{{ category }}</li>
{% endfor %}
</ul>
{% endblock %}.. nextslide:: Hook it Up
In order to view a single post, we'll need a link from the list view
.. rst-class:: build
We can use the url template tag (like Pyramid's request.route_url):
{% url '<view_name>' arg1 arg2 %}In our list.html template, let's link the post titles:
{% for post in posts %}
<div class="post">
<h2>
<a href="{% url 'blog_detail' post.pk %}">{{ post }}</a>
</h2>
..... nextslide:: Fix URLs
Again, we need to insert our new view into the existing myblog/urls.py in
myblog:
# import the view
from myblog.views import detail_view
url(r'^posts/(?P<post_id>\d+)/$',
detail_view, #<-- Change this from stub_view
name="blog_detail"),.. rst-class:: build small
(djangoenv)$ ./manage.py test myblog ... Ran 4 tests in 0.077s OK
.. nextslide:: A Moment To Play
We've got some good stuff to look at now. Fire up the server
.. rst-class:: build
Reload your blog index page and click around a bit.
You can now move back and forth between list and detail view.
Try loading the detail view for a post that doesn't exist
.. nextslide:: Congratulations
You've got a functional Blog
.. rst-class:: build
It's not very pretty, though.
We can fix that by adding some css
This gives us a chance to learn about Django's handling of static files
Like templates, Django expects to find static files in particular locations
.. rst-class:: build
It will look for them in a directory named static in any installed
apps.
They will be served from the url path in the STATIC_URL setting.
By default, this is /static/
To allow Django to automatically build the correct urls for your static files, you use a special template tag:
{% static <filename> %}
.. nextslide:: Add CSS
I've prepared a css file for us to use. You can find it in the class resources
.. rst-class:: build
Create a new directory static in the myblog app.
Copy the django_blog.css file into that new directory.
Next, load the static files template tag into base.html (this
must be on the first line of the template):
{% load staticfiles %}Finally, add a link to the stylesheet using the special template tag:
<title>My Django Blog</title> <!-- This is already present -->
<link type="text/css" rel="stylesheet" href="{% static 'django_blog.css' %}">.. nextslide:: View Your Results
Reload http://localhost:8000/ and view the results of your work
.. rst-class:: build
We now have a reasonable view of the posts of our blog on the front end
And we have a way to create and categorize posts using the admin
However, we lack a way to move between the two.
Let's add that ability next.
We'll start by adding a control bar to our base.html template:
<!DOCTYPE html>
...
<div id="header">
<ul id="control-bar">
{% if user.is_authenticated %}
{% if user.is_staff %}<li>admin</li>{% endif %}
<li>logout</li>
{% else %}
<li>login</li>
{% endif %}
</ul>
</div>
<div id="container">
..... nextslide:: Request Context Revisited
When we set up our views, we used the render shortcut, which provides a
RequestContext
.. rst-class:: build
This gives us access to user in our templates
It provides access to methods about the state and rights of that user
We can use these to conditionally display links or UI elements. Like only showing the admin link to staff members.
.. nextslide:: Login/Logout
Django also provides a reasonable set of views for login/logout.
.. rst-class:: build
The first step to using them is to hook them into a urlconf.
Add the following to mysite/urls.py:
# add an import at the top
from django.contrib.auth.views import login, logout
# and update the list of urlconfs
url(r'^', include('myblog.urls')), #<- already there
url(r'^login/$',
login,
{'template_name': 'login.html'},
name="login"),
url(r'^logout/$',
logout,
{'next_page': '/'},
name="logout"),.. nextslide:: Login Template
We need to create a new login.html template in mysite/templates:
{% extends "base.html" %}
{% block content %}
<h1>My Blog Login</h1>
<form action="" method="POST">{% csrf_token %}
{{ form.as_p }}
<p><input type="submit" value="Log In"></p>
</form>
{% endblock %}.. nextslide:: Submitting Forms
In a web application, submitting forms is potentially hazardous
.. rst-class:: build
Data is being sent to our application from some remote place
If that data is going to alter the state of our application, we must use POST
Even so, we are vulnerable to Cross-Site Request Forgery, a common attack vector.
.. nextslide:: Danger: CSRF
Django provides a convenient system to fight this.
.. rst-class:: build
In fact, for POST requests, it requires that you use it.
The Django middleware that does this is enabled by default.
All you need to do is include the {% csrf_token %} tag in your form.
.. nextslide:: Hooking It Up
In base.html make the following updates:
.. rst-class:: build
<!-- admin link -->
<a href="{% url 'admin:index' %}">admin</a>
<!-- logout link -->
<a href="{% url 'logout' %}">logout</a>
<!-- login link -->
<a href="{% url 'login' %}">login</a>Finally, in settings.py add the following:
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'.. nextslide:: Forms In Django
In adding a login view, we've gotten a sneak peak at how forms work in Django.
.. rst-class:: build
However, learning more about them is beyond what we can achieve in this session.
The form system in Django is quite nice, however. I urge you to read more about it
In particular, you might want to pay attention to the documentation on Model Forms
So, that's it. We've created a workable, simple blog app in Django.
.. rst-class:: build
If you fell behind at some point, the app as it now stands is in our class
resources as mysite_stage_3.
There's much more we could do with this app. And for homework, you'll do some of it.
Then next session, we'll work together as pairs to implement a simple feature to extend the blog
.. rst-class:: left
For your homework this week, we'll fix one glaring problem with our blog admin.
.. rst-class:: build left
As you created new categories and posts, and related them to each-other, how did you feel about that work?
Although from a data perspective, the category model is the right place for the ManytoMany relationship to posts, this leads to awkward usage in the admin.
It would be much easier if we could designate a category for a post from the Post admin.
You'll be reversing that relationship so that you can only add categories to posts
.. rst-class:: build
Take the following steps:
- Read the documentation about the Django admin.
- You'll need to create a customized ModelAdmin class for the
PostandCategorymodels. - And you'll need to create an InlineModelAdmin to represent Categories on the Post admin view.
- Finally, you'll need to exclude the 'posts' field from the form in
your
Categoryadmin.
.. nextslide:: Pushing Further
All told, those changes should not require more than about 15 total lines of code.
.. rst-class:: build
The trick of course is reading and finding out which fifteen lines to write.
If you complete that task in less than 3-4 hours of work, consider looking into other ways of customizing the admin.
.. nextslide:: Tasks you might consider
.. rst-class:: build
- Change the admin index to say 'Categories' instead of 'Categorys'. (hint, the way to change this has nothing to do with the admin)
- Add columns for the date fields to the list display of Posts.
- Display the created and modified dates for your posts when viewing them in the admin.
- Add a column to the list display of Posts that shows the author. For more fun, make this a link that takes you to the admin page for that user.
- For the biggest challenge, look into admin actions and add an action to the Post admin that allows you to publish posts in bulk from the Post list display