With Flask you assembled every piece of the Papyrus website by hand: you chose where to store the catalog (M6's JSON), you wrote the POST validation field by field, and if tomorrow you wanted a panel so Marta could edit books without touching code, you'd have to build it. Django is the other school's answer: a batteries-included framework that ships with persistence, an admin panel, validated forms, user management and more. In this lesson you'll start a Django project from scratch, understand its MTV architecture and its file structure (which it imposes rather than suggests), redefine M5's Book as a model with persistence thrown in for free, and finish by editing the catalog from the admin — without having written a single line of HTML. The same Papyrus, the other road.
Contents
- Philosophy: batteries included (when Django, when Flask)
- Installation and
startproject:papyrus_webis born - Project vs app:
startapp catalog - The file structure, file by file
- The MTV pattern (and its cousin MVC)
- First view:
HttpResponseandurls.py - The
Bookmodel: from dataclass tomodels.Model - Migrations:
makemigrationsandmigrate - The Django admin: the star gift
runserverand the complete map
Philosophy: batteries included (when Django, when Flask)
Flask gives you a minimal core and total freedom; Django gives you a complete appliance and its instruction manual. Neither is "better": they solve different contexts.
| Criterion | Flask | Django |
|---|---|---|
| Persistence | You choose it (our M6 JSON) | ORM included: models that save themselves |
| Admin panel | You build it | Included and automatic |
| Validated forms | By hand (the 10-03 POST) | ModelForm included (10-05) |
| Users and sessions | Third-party extensions | Included |
| Project structure | You decide it | Django imposes it (and that's good in a team) |
| Ideal for | APIs, small projects, learning piece by piece | Complete applications with standard parts |
| Typical risk | Reinventing wheels | Carrying pieces you don't use |
Practical rule: if your project is mostly an API or something small and unusual, Flask; if it's "a classic web application" — catalog, data entry, users, administration — Django saves you weeks. Papyrus, which is exactly that, is a textbook case (pun intended).
Installation and startproject: papyrus_web is born
In a fresh virtual environment (M1: one venv per project):
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install django
django-admin startproject papyrus_web
cd papyrus_webpip install django also installed the django-admin command, the project generator. startproject papyrus_web creates a folder with the complete skeleton — the first cultural difference from Flask, where you started with an empty app.py: Django hands you the structure ready-made because its philosophy is that all Django projects should look alike.
Project vs app: startapp catalog
Django distinguishes two concepts worth pinning down early:
- The project (
papyrus_web) is the whole: global configuration, root URLs, the entire site. - An app is a functional component with its own models, views and templates: the catalog, someday the reservations, perhaps the members. A project contains several apps; a well-built app could be reused in another project.
We create the first one:
And we register it with the project, in papyrus_web/settings.py, so Django takes it into account:
INSTALLED_APPS = [
"django.contrib.admin", # the admin panel
"django.contrib.auth", # users and permissions
# ... other included batteries ...
"catalog", # ← our app
]That INSTALLED_APPS is literally the list of connected batteries: notice how many things come switched on from the factory.
The file structure, file by file
After the two commands, the tree looks like this (the important files, annotated):
papyrus_web/ ← project root folder
├── manage.py ← your Swiss army knife: runserver, migrate, startapp...
├── papyrus_web/ ← the project's "heart" (configuration)
│ ├── settings.py ← global configuration
│ ├── urls.py ← root URL table
│ └── wsgi.py / asgi.py ← entry points for servers (production)
└── catalog/ ← the app
├── models.py ← the data: Book will be reborn here
├── views.py ← the views (like Flask's)
├── admin.py ← what gets managed from the panel
├── migrations/ ← history of model changes
└── tests.py ← Django believes in tests too (M9 smiles)| File | Responsibility | Flask equivalent |
|---|---|---|
manage.py |
Run project commands | the flask CLI |
settings.py |
Configuration: apps, database, language, time zone | the little you configured in app.py |
urls.py |
Map URLs → views | the @app.route calls (no decorator here!) |
views.py |
Receive request, return response | your view functions |
models.py |
Define the data and its persistence | your models.py + warehouse.py + JSON |
admin.py |
Configure the admin panel | doesn't exist: you'd build it |
One courtesy tweak in settings.py before moving on: the default LANGUAGE_CODE = "en-us" already suits us (the admin will speak English out of the box), so we only set TIME_ZONE = "Europe/Madrid" — Papyrus keeps shop hours in Madrid time.
The MTV pattern (and its cousin MVC)
Django organizes code according to the MTV pattern: Model–Template–View. It's the separation of responsibilities we've been practicing since M3, with proper names:
flowchart LR
P["Request<br/>GET /books/"] --> U["urls.py<br/>which view handles this?"]
U --> V["View (views.py)<br/>logic: what data is needed"]
V --> M["Model (models.py)<br/>the data: Book and its persistence"]
M --> V
V --> T["Template<br/>HTML with holes"]
T --> R["HTML response"]
- Model: the data and the rules for accessing it. In Papyrus-Flask this was the dataclass plus
warehouse.pyplus the JSON; in Django it will be a single class. - Template: the presentation — like 10-02's Jinja2 templates, with almost identical syntax.
- View: the logic connecting the two — it receives the request, queries models, picks a template.
If you've heard of MVC (Model-View-Controller), the classic pattern: it's the same thing with the labels shuffled. Django's "View" plays the role of MVC's Controller, and the Template plays MVC's "View". Confusing to name, identical in spirit: data, logic and presentation, each in its place.
First view: HttpResponse and urls.py
The Django "hello world", to see the mechanism laid bare. In catalog/views.py:
from django.http import HttpResponse
def home(request):
return HttpResponse("<h1>Papyrus, your neighborhood bookshop</h1>")Two differences from Flask jump out: the function always receives request as its first parameter (in Flask it was a global object you imported), and there is no route decorator. In Django, URLs live apart from views, in papyrus_web/urls.py:
from django.contrib import admin
from django.urls import path
from catalog import views
urlpatterns = [
path("admin/", admin.site.urls), # the panel, already plugged in
path("", views.home), # "" = the site root
]path(route, view) does what @app.route did: record it in the table. The difference is philosophical: Django prefers all URLs together in one index rather than scattered across decorators — in large projects that single index is a blessing. Start it up and see for yourself:
http://127.0.0.1:8000 (Django uses port 8000; Flask, 5000) shows your greeting. runserver includes automatic reloading, like Flask's debug mode — and with the same warning: it's a development server, not a production one.
The Book model: from dataclass to models.Model
Here comes the lesson's star moment. Remember the M5 dataclass:
Its Django reincarnation, in catalog/models.py:
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
price = models.DecimalField(max_digits=6, decimal_places=2)
stock = models.IntegerField(default=0)
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
def final_price(self, member=False):
price = float(self.price) * (1 + self.BOOK_VAT)
if member:
price *= 1 - self.MEMBER_DISCOUNT
return round(price, 2)
def in_stock(self):
return self.stock > 0
def __str__(self):
return self.titleCompare member by member:
| Dataclass (M5) | Django model | What Django adds |
|---|---|---|
title: str |
CharField(max_length=200) |
Maximum length, validation |
price: float |
DecimalField(max_digits=6, decimal_places=2) |
Exact decimals for money (an improvement!) |
stock: int = 0 |
IntegerField(default=0) |
Type and default value persisted |
| — | inherits from models.Model |
Saving, loading, querying: free |
The same three fields as always, the same final_price() and in_stock() methods (Hamlet will still cost 9.83 for members — the M9 invariants travel with us). The novelty is what you don't see: by inheriting from models.Model (M5 inheritance in action), every Book knows how to save itself with book.save() and be queried with Book.objects... (we'll squeeze that in 10-05). In Flask you wrote load_catalog and save_catalog on top of JSON; this is the persistence Django gives you for free. Underneath it uses a SQLite database — a db.sqlite3 file in your project, nothing to install or configure. SQL database theory is beyond this course: today it's enough that the gift works. A professional bonus detail: DecimalField avoids float's odd roundings with money — Django even improves on our dataclass.
Migrations: makemigrations and migrate
Defining the model creates nothing yet: you have to tell the database what shape the data has. That dialogue is the migrations, in two steps:
python manage.py makemigrations catalog
# Migrations for 'catalog':
# catalog/migrations/0001_initial.py
# + Create model Book
python manage.py migrate
# Applying catalog.0001_initial... OK (plus admin's, auth's...)Conceptually:
makemigrationscompares yourmodels.pywith the last known state and writes the recipe for the change (a Python file inmigrations/): "create the Book table with these columns".migrateapplies the pending recipes to the actual database.
It's version control for the shape of your data: every future model change (adding an author field, for example) will generate its 0002_... migration, and any teammate reproduces your database by running migrate. You'll notice the first run also applies admin and auth migrations: those are the included batteries setting up their own tables.
The Django admin: the star gift
And now, the show of force. Three lines in catalog/admin.py:
A superuser to log in with (it will ask for a username and password in the terminal):
Start runserver, go to http://127.0.0.1:8000/admin/, sign in and behold: a complete admin panel — book listing, creation form with type validation, editing, deletion with confirmation, search — without having written a single template or a single view. Enter the canonical catalog: The Odyssey (12.50, 4), Hamlet (9.95, 6), Don Quixote (15.90, 8) and Faust (21.00, 10). Marta can now manage the inventory from the browser; in Flask, that panel would have cost you several afternoons. The __str__ we defined is what the admin shows in listings — without it you'd see "Book object (1)".
runserver and the complete map
The Django work cycle you'll repeat a thousand times, summarized:
python manage.py runserver # develop and test
# ... you edit models.py ...
python manage.py makemigrations # record the data change
python manage.py migrate # apply itCommon Mistakes and Tips
- Forgetting to register the app in
INSTALLED_APPS: the classic symptom ismakemigrationsanswering "No changes detected". Django only looks at registered apps. - Editing
models.pyand not migrating: on access you'll seeOperationalError: no such table. Changed model =makemigrations+migrate, always, in that order. - Confusing
django-adminwithmanage.py:django-adminonly forstartproject; after that, everything withpython manage.py ..., which loads your project's configuration. - Using
FloatFieldfor money: the temptation comes from the dataclass.DecimalFieldexists precisely for amounts; get used to it from your very first model. - Deleting
db.sqlite3ormigrations/"to fix" an error: while learning you can afford it, but it's dynamite: you lose data and history. Better to understand the error — it's almost always an unapplied migration. - Looking for the
if __name__ == "__main__": in Django you don't start the application;manage.pydoes. Let go of the reins: the framework calls your code, not the other way around (that is, literally, what defines a framework).
Exercises
Exercise 1: the author field
The canonical catalog always had authors (Homer, Shakespeare, Cervantes, Goethe) and the model doesn't store them. Add author = models.CharField(max_length=100, default="") to the model, generate and apply the migration, and fill in the authors from the admin. Adjust __str__ to show "Hamlet — Shakespeare".
Exercise 2: a members page
Add the view members (using HttpResponse and minimal HTML listing Luis, Marta and Pau with their cards LUIS-001, MARTA-002 and PAU-003) and wire it up to the URL /members/. Check it in the browser.
Exercise 3: member prices in the Django console
python manage.py shell opens an interpreter with your project loaded. Use it to loop over all the books and print title: final_price(member=True). Verify against the canonical values: 12.35, 9.83, 15.71 and 20.75. (Unavoidable preview: for "all the books" you'll need Book.objects.all() — the doorway to 10-05.)
Solutions
Exercise 1:
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100, default="")
# ... rest unchanged ...
def __str__(self):
return f"{self.title} — {self.author}"The default="" is necessary: the database already contains books and Django needs to know what to put in the new column for the existing rows (if you don't provide it, makemigrations will ask you interactively).
Exercise 2:
# catalog/views.py
def members(request):
return HttpResponse(
"<h1>Papyrus members</h1>"
"<ul><li>Luis — LUIS-001</li>"
"<li>Marta — MARTA-002</li>"
"<li>Pau — PAU-003</li></ul>"
)# papyrus_web/urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.home),
path("members/", views.members),
]HTML in strings again — just as awkward as in Flask. The Django templates that fix it arrive in 10-05.
Exercise 3:
>>> from catalog.models import Book
>>> for book in Book.objects.all():
... print(f"{book.title}: {book.final_price(member=True)}")
The Odyssey: 12.35
Hamlet: 9.83
Don Quixote: 15.71
Faust: 20.75The four canonical figures, now coming out of a database instead of a JSON file. Book.objects.all() is your first QuerySet: the full query syntax awaits you in the next lesson.
Conclusion
Django has shown its cards: an imposed but clear structure (settings.py configures, urls.py routes, views.py responds, models.py defines the data), the MTV pattern separating data, logic and presentation, and above all the batteries — the Book model that, by inheriting from models.Model, gains the persistence you hand-coded over JSON in Flask, the migrations that version the shape of your data, and that admin which turned Marta into the inventory manager with three lines of code. The M5 dataclass and the Django model are the same book with a different cover: same data, same final_price() and in_stock(), same 12.35, 9.83, 15.71 and 20.75. But for now visitors only see an HttpResponse with embedded HTML: no browsable catalog, no searches, no public book entry. In the next lesson we'll complete the app: queries with QuerySets, templates with inheritance (they'll feel very familiar), forms with ModelForm that validate themselves, and the Papyrus website finished for the second time — same functionality, two roads, and you able to walk both.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
