🐍 Ngày 46 Python 365 ngày | Xây dựng ứng dụng web với Django
🗓 Ngày 46: Django — framework "pin kèm mọi thứ"
Hôm qua bạn học Flask: nhỏ, linh hoạt, nhưng bạn phải tự chọn mọi thứ. Hôm nay ta đi ngược lại: Django mang sẵn ORM, admin, authentication, migrations, form, bảo mật. Triết lý của Django là "batteries included" — pin đã kèm trong hộp.
🎯 Mục tiêu bài học
- Hiểu kiến trúc MTV (Model – Template – View) của Django.
- Tạo project và app đúng chuẩn.
- Định nghĩa Model và chạy migrations.
- Đọc/ghi dữ liệu bằng Django ORM.
- Bật Django Admin để quản trị dữ liệu miễn phí.
- Viết View + Template hiển thị dữ liệu.
Dự án thực hành: Ứng dụng quản lý danh mục đầu tư — theo dõi các khoản đầu tư và lãi/lỗ.
🧠 Django hoạt động thế nào?
flowchart LR
A[Browser] -->|URL| B[urls.py]
B --> C[views.py]
C --> D[models.py]
D --> E[(Database)]
C --> F[Template]
F --> A
| Thành phần | Vai trò | Tương đương Flask |
|---|---|---|
urls.py | Bản đồ URL → view | @app.route |
views.py | Xử lý logic, trả response | Hàm route |
models.py | Định nghĩa bảng dữ liệu | SQLAlchemy model |
templates/ | Giao diện | Jinja2 |
admin.py | Giao diện quản trị tự sinh | (không có) |
settings.py | Cấu hình toàn dự án | app.config |
🧰 Cài đặt và khởi tạo
python -m venv .venv
.venv\Scripts\activate # Windows
pip install django
django-admin --version # kiểm tra
# Tạo project
django-admin startproject danhmuc .
python manage.py startapp portofolio
Cấu trúc:
danhmuc/
├── manage.py
├── danhmuc/
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── portofolio/
├── models.py
├── views.py
├── urls.py ← tự tạo
├── admin.py
└── migrations/
🔌 Bước 1: Đăng ký app và chạy server
danhmuc/settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"portofolio", # ← thêm app của bạn
]
LANGUAGE_CODE = "vi"
TIME_ZONE = "Asia/Ho_Chi_Minh"
USE_TZ = True
python manage.py migrate # tạo bảng hệ thống
python manage.py runserver
Mở http://127.0.0.1:8000 — bạn thấy trang chào mừng Django.
🗄️ Bước 2: Model — định nghĩa dữ liệu bằng Python
portofolio/models.py:
from django.db import models
from django.contrib.auth.models import User
class TaiSan(models.Model):
"""Một tài sản trong danh mục (cổ phiếu, crypto, vàng...)."""
LOAI_CHON = [
("CP", "Cổ phiếu"),
("CC", "Crypto"),
("VG", "Vàng"),
("TP", "Trái phiếu"),
]
ma = models.CharField("Mã", max_length=20, unique=True)
ten = models.CharField("Tên tài sản", max_length=120)
loai = models.CharField("Loại", max_length=2, choices=LOAI_CHON, default="CP")
ghi_chu = models.TextField("Ghi chú", blank=True)
ngay_tao = models.DateTimeField("Ngày tạo", auto_now_add=True)
class Meta:
verbose_name = "Tài sản"
verbose_name_plural = "Tài sản"
ordering = ["ma"]
def __str__(self):
return f"{self.ma} — {self.ten}"
class GiaoDich(models.Model):
"""Một lần mua/bán."""
LOAI_GD = [("MUA", "Mua"), ("BAN", "Bán")]
tai_san = models.ForeignKey(
TaiSan, on_delete=models.CASCADE, related_name="giao_dich",
verbose_name="Tài sản",
)
loai = models.CharField("Loại giao dịch", max_length=3, choices=LOAI_GD)
so_luong = models.DecimalField("Số lượng", max_digits=18, decimal_places=6)
gia = models.DecimalField("Giá", max_digits=18, decimal_places=2)
phi = models.DecimalField("Phí", max_digits=18, decimal_places=2, default=0)
thoi_diem = models.DateTimeField("Thời điểm")
class Meta:
ordering = ["-thoi_diem"]
@property
def gia_tri(self):
return self.so_luong * self.gia
def __str__(self):
return f"{self.get_loai_display()} {self.so_luong} {self.tai_san.ma}"
Điểm quan trọng:
ForeignKey+on_delete— bắt buộc trong Django hiện đại.DecimalFieldcho tiền — không dùngFloatFieldcho giá trị tài chính (sai số làm tròn!).choicestự sinhget_<field>_display()và dropdown trong admin.__str__quyết định cách object hiển thị trong admin.
🔄 Bước 3: Migrations — biến model thành bảng
python manage.py makemigrations portofolio
python manage.py migrate
Kết quả:
Migrations for 'portofolio':
portofolio/migrations/0001_initial.py
- Create model TaiSan
- Create model GiaoDich
Quy trình bất di bất dịch:
Sửa models.py → makemigrations → migrate
⚠️ Không bao giờ sửa file trong
migrations/bằng tay. Chúng là lịch sử thay đổi schema — sửa tay sẽ gây lệch giữa các máy.
👑 Bước 4: Django Admin — giao diện quản trị miễn phí
python manage.py createsuperuser
# nhập username, email, password
portofolio/admin.py:
from django.contrib import admin
from .models import TaiSan, GiaoDich
@admin.register(TaiSan)
class TaiSanAdmin(admin.ModelAdmin):
list_display = ("ma", "ten", "loai", "ngay_tao")
list_filter = ("loai",)
search_fields = ("ma", "ten")
@admin.register(GiaoDich)
class GiaoDichAdmin(admin.ModelAdmin):
list_display = ("tai_san", "loai", "so_luong", "gia", "thoi_diem")
list_filter = ("loai", "tai_san")
date_hierarchy = "thoi_diem"
autocomplete_fields = ("tai_san",)
Mở http://127.0.0.1:8000/admin — bạn có CRUD đầy đủ, tìm kiếm, lọc. Đây là lý do lớn nhất nhiều người chọn Django.
🐍 Bước 5: Django ORM — thao tác dữ liệu không cần SQL
Mở shell:
python manage.py shell
from portofolio.models import TaiSan, GiaoDich
from django.utils import timezone
from decimal import Decimal
# --- CREATE ---
btc = TaiSan.objects.create(ma="BTC", ten="Bitcoin", loai="CC")
fpt = TaiSan.objects.create(ma="FPT", ten="FPT Corporation", loai="CP")
GiaoDich.objects.create(
tai_san=btc, loai="MUA",
so_luong=Decimal("0.5"), gia=Decimal("62000"),
thoi_diem=timezone.now(),
)
# --- READ ---
TaiSan.objects.all() # tất cả
TaiSan.objects.filter(loai="CC") # lọc
TaiSan.objects.get(ma="BTC") # một bản ghi (lỗi nếu 0 hoặc >1)
TaiSan.objects.exclude(loai="CP") # loại trừ
TaiSan.objects.filter(ten__icontains="bit") # tìm gần đúng, không phân biệt hoa thường
TaiSan.objects.order_by("-ngay_tao")[:5] # sắp xếp + giới hạn
# --- UPDATE ---
btc.ten = "Bitcoin (BTC)"
btc.save()
TaiSan.objects.filter(loai="CC").update(ghi_chu="Tài sản biến động cao")
# --- DELETE ---
TaiSan.objects.filter(ma="XYZ").delete()
# --- Truy vấn ngược qua related_name ---
btc.giao_dich.all() # tất cả giao dịch của BTC
# --- Aggregate ---
from django.db.models import Sum, Count, Avg
GiaoDich.objects.aggregate(tong=Sum("gia"), so_lenh=Count("id"))
# --- Tránh N+1 query ---
GiaoDich.objects.select_related("tai_san").all()
Lookup phổ biến:
| Lookup | Ý nghĩa | Ví dụ |
|---|---|---|
__exact | Bằng (mặc định) | gia__exact=100 |
__gt / __lt | Lớn/nhỏ hơn | gia__gt=50000 |
__gte / __lte | Lớn/nhỏ hơn hoặc bằng | so_luong__lte=1 |
__contains / __icontains | Chứa chuỗi | ten__icontains="bank" |
__in | Nằm trong danh sách | loai__in=["CP","CC"] |
__isnull | Là NULL | ghi_chu__isnull=True |
__range | Trong khoảng | gia__range=(100, 500) |
🖥️ Bước 6: View và URL
portofolio/views.py:
from django.shortcuts import render, get_object_or_404
from django.db.models import Sum, F, DecimalField, ExpressionWrapper, Count
from .models import TaiSan, GiaoDich
def danh_sach_tai_san(request):
tai_san = TaiSan.objects.annotate(so_giao_dich=Count("giao_dich"))
return render(request, "portofolio/danh_sach.html", {
"tai_san": tai_san,
"tieu_de": "Danh mục tài sản",
})
def chi_tiet_tai_san(request, ma):
ts = get_object_or_404(TaiSan, ma=ma)
giao_dich = ts.giao_dich.select_related("tai_san").all()
# Tổng giá trị đã mua
tong_mua = giao_dich.filter(loai="MUA").aggregate(
t=Sum(ExpressionWrapper(
F("so_luong") * F("gia"),
output_field=DecimalField(max_digits=24, decimal_places=6),
))
)["t"] or 0
return render(request, "portofolio/chi_tiet.html", {
"tai_san": ts,
"giao_dich": giao_dich,
"tong_mua": tong_mua,
})
portofolio/urls.py:
from django.urls import path
from . import views
app_name = "portofolio"
urlpatterns = [
path("", views.danh_sach_tai_san, name="danh_sach"),
path("tai-san/<str:ma>/", views.chi_tiet_tai_san, name="chi_tiet"),
]
danhmuc/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("danh-muc/", include("portofolio.urls")),
]
🎨 Bước 7: Template
portofolio/templates/portofolio/base.html:
{% load static %}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Danh mục đầu tư{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
<body>
<nav>
<a href="{% url 'portofolio:danh_sach' %}">Danh mục</a> |
<a href="/admin/">Quản trị</a>
</nav>
<main>{% block noi_dung %}{% endblock %}</main>
</body>
</html>
portofolio/templates/portofolio/danh_sach.html:
{% extends "portofolio/base.html" %}
{% block title %}{{ tieu_de }}{% endblock %}
{% block noi_dung %}
<h1>{{ tieu_de }}</h1>
{% if tai_san %}
<table>
<thead>
<tr><th>Mã</th><th>Tên</th><th>Loại</th><th>Số giao dịch</th><th></th></tr>
</thead>
<tbody>
{% for ts in tai_san %}
<tr>
<td><strong>{{ ts.ma }}</strong></td>
<td>{{ ts.ten }}</td>
<td>{{ ts.get_loai_display }}</td>
<td>{{ ts.so_giao_dich }}</td>
<td><a href="{% url 'portofolio:chi_tiet' ts.ma %}">Xem</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Chưa có tài sản nào. Hãy thêm trong <a href="/admin/">trang quản trị</a>.</p>
{% endif %}
{% endblock %}
💡 Khác biệt với Flask: Django template không cho gọi hàm có tham số (
{{ obj.method(arg) }}), và thẻ{% url 'app:name' %}thay chourl_for. Django ưu tiên an toàn hơn là linh hoạt.
🧪 Bài tập thực hành
- Thêm model
DanhMucđể nhóm nhiều tài sản lại (ManyToManyField). - Viết view
/thong-ke/hiển thị tổng giá trị danh mục, tổng lãi/lỗ. - Thêm chức năng thêm giao dịch bằng Django Form (
forms.ModelForm). - Dùng
ListViewvàDetailView(class-based views) thay cho function view. - Thêm tìm kiếm theo mã tài sản qua query string
?q=. - Cấu hình
django-debug-toolbarvà tìm hiểu một vấn đề N+1 query.
⚠️ Lỗi thường gặp
| Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
no such table | Chưa migrate | python manage.py migrate |
TemplateDoesNotExist | Sai cấu trúc thư mục | Cần app/templates/app/file.html |
AppRegistryNotReady | Import model quá sớm | Import trong hàm thay vì đầu file |
TemplateSyntaxError: Invalid block tag | Gõ sai cú pháp | Django dùng {% %}, không dùng ( ) |
Reverse for 'x' not found | Sai name= trong urls.py | Kiểm tra app_name + tên route |
| Decimal + float lỗi | Trộn float với Decimal | Dùng Decimal("...") cho mọi giá trị tiền |
📝 Ghi chú
- Django ORM lazy: QuerySet chưa chạy cho tới khi bạn lặp/
list()/len(). Hiểu điều này để tránh query thừa. select_relatedvsprefetch_related:select_relatedchoForeignKey/OneToOne (JOIN),prefetch_relatedcho ManyToMany (query riêng).settings.pychứaSECRET_KEY— khi deploy phải đọc từ biến môi trường và đặtDEBUG = False.- CSRF: Django bật sẵn. Mọi form POST cần
{% csrf_token %}. - Django migrations không thay thế backup. Trước khi migrate trên production, hãy backup database.
🎯 Tóm tắt ngày 46
✅ Hiểu kiến trúc MTV: Model → Template → View
✅ Tạo project/app, model với ForeignKey, choices, DecimalField
✅ Chạy migration và hiểu tại sao không sửa tay
✅ Bật Django Admin có tìm kiếm/lọc/CRUD
✅ Thành thạo lookup ORM cơ bản và aggregate
✅ Viết view + template với {% url %} và {% extends %}