Chuyển tới nội dung chính

1 bài viết được gắn thẻ "Django"

Xem tất cả thẻ

Lộ trình học Python Django Backend

· 25 phút để đọc

Django là framework "pin kèm mọi thứ" của Python: ORM, admin, xác thực, form, migration, bảo mật — tất cả có sẵn. Triết lý của nó là quyết định thay bạn để bạn không phải chọn lại từ đầu mỗi dự án.

Điều đó có nghĩa Django phù hợp nhất khi bạn cần giao sản phẩm nhanh và làm việc nhóm đông người với quy ước chung.


🎯 Django phù hợp với ai?​

Nên chọn Django khiNên chọn framework khác khi
Làm web app có giao diện + adminCần API siêu nhẹ, ít tính năng (→ Flask)
Cần CRUD nhanh cho nhiều bảngCần async native, WebSocket nhiều (→ FastAPI)
Team đông, cần quy ước chungMuốn toàn quyền kiểm soát từng thành phần (→ Flask)
Cần auth, phân quyền, bảo mật sẵnPrototype vài chục dòng
Làm CMS, ERP, hệ thống nội bộ—

💡 Lợi thế độc nhất: Django Admin. Chỉ với 10 dòng admin.py, bạn có giao diện quản trị có tìm kiếm, lọc, phân quyền, export. Xây thứ tương tự bằng Flask mất vài tuần.


🗓 Lộ trình 16 tuần​

Tuần 1–2    Python + kiến thức web cơ bản (HTTP, SQL)
Tuần 3–4 Django cơ bản: project, app, view, template, URL
Tuần 5–7 Model, migration, Django ORM, admin
Tuần 8–9 Form, class-based view, xác thực
Tuần 10–11 Django REST Framework: serializer, viewset, router
Tuần 12 Testing, tối ưu truy vấn, cache
Tuần 13 Celery, tác vụ nền, signal
Tuần 14–15 Docker, deploy production, bảo mật
Tuần 16 Dự án tổng hợp + phỏng vấn

🐍 Tuần 1–2: Nền tảng bắt buộc​

Trước khi học Django, bạn cần chắc hai thứ mà nhiều người bỏ qua rồi trả giá:

1. SQL — vì ORM không cứu bạn khi truy vấn chậm:

SELECT t.ten, COUNT(s.id) AS so_sach, SUM(s.gia) AS tong
FROM tac_gia t
JOIN sach s ON s.tac_gia_id = t.id
WHERE s.nam_xuat_ban >= 2020
GROUP BY t.id, t.ten
HAVING COUNT(s.id) > 2
ORDER BY tong DESC
LIMIT 10;

Hiểu JOIN, GROUP BY, INDEX, EXPLAIN là điều kiện để debug QuerySet của Django.

2. HTTP — vì Django là framework web:

Thành phầnCần hiểu
MethodGET, POST, PUT, PATCH, DELETE — và tính idempotent của từng cái
Mã trạng thái200, 201, 204, 400, 401, 403, 404, 500
HeaderContent-Type, Authorization, Cookie
Cơ chếRequest/response, session, cookie, CSRF

🌱 Tuần 3–4: Django cơ bản​

python -m venv .venv
.venv\Scripts\activate
pip install django
django-admin startproject config .
python manage.py startapp blog

Vì sao dùng config làm tên project: tránh trùng tên với package bên trong (nhiều người đặt project là mysite rồi gặp lỗi import khó hiểu sau này).

MTV — kiến trúc của Django​

flowchart LR
A[Browser] -->|URL| B[urls.py]
B --> C[views.py]
C --> D[models.py ORM]
D --> E[(Database)]
C --> F[Template]
F --> A
Thành phầnVai tròSo với MVC
ModelĐịnh nghĩa dữ liệuGiống Model
TemplateHiển thịGiống View
ViewXử lý logicGiống Controller

URL và View​

# blog/views.py
from django.http import JsonResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages

from .models import BaiViet


def danh_sach_bai_viet(request):
ds = BaiViet.objects.filter(da_xuat_ban=True).select_related("tac_gia")
return render(request, "blog/danh_sach.html", {"bai_viet": ds})


def chi_tiet_bai_viet(request, slug: str):
bai_viet = get_object_or_404(BaiViet, slug=slug, da_xuat_ban=True)
return render(request, "blog/chi_tiet.html", {"bai_viet": bai_viet})
# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
path("", views.danh_sach_bai_viet, name="danh_sach"),
path("<slug:slug>/", views.chi_tiet_bai_viet, name="chi_tiet"),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")),
]

Bốn loại path converter:

ConverterKhớp vớiVí dụ
<int:id>Số nguyên/bai-viet/42/
<slug:slug>Chuỗi slug hoá/bai-viet/hoc-python-co-ban/
<str:ten>Chuỗi không có //tac-gia/minh/
<uuid:ma>UUID/don-hang/550e8400-.../

⚠️ Luôn dùng get_object_or_404 thay vì BaiViet.objects.get(...). get() ném DoesNotExist → lỗi 500. get_object_or_404 mới trả 404 đúng.


🗄️ Tuần 5–7: Model, Migration và ORM​

Model đúng chuẩn​

# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify


class DanhMuc(models.Model):
ten = models.CharField("Tên danh mục", max_length=100, unique=True)
slug = models.SlugField("Đường dẫn", max_length=120, unique=True)

class Meta:
verbose_name = "Danh mục"
verbose_name_plural = "Danh mục"
ordering = ["ten"]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.ten, allow_unicode=True)
super().save(*args, **kwargs)

def __str__(self):
return self.ten


class BaiViet(models.Model):
TRANG_THAI = [
("nhap", "Bản nháp"),
("cho_duyet", "Chờ duyệt"),
("xuat_ban", "Đã xuất bản"),
]

tieu_de = models.CharField("Tiêu đề", max_length=250)
slug = models.SlugField("Đường dẫn", max_length=260, unique=True, db_index=True)
noi_dung = models.TextField("Nội dung")
tom_tat = models.CharField("Tóm tắt", max_length=500, blank=True)

tac_gia = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="bai_viet",
verbose_name="Tác giả",
)
danh_muc = models.ForeignKey(
DanhMuc, on_delete=models.SET_NULL, null=True, blank=True,
related_name="bai_viet", verbose_name="Danh mục",
)
the = models.ManyToManyField("The", blank=True, related_name="bai_viet", verbose_name="Thẻ")

trang_thai = models.CharField(max_length=20, choices=TRANG_THAI, default="nhap", db_index=True)
luot_xem = models.PositiveIntegerField("Lượt xem", default=0)
ngay_tao = models.DateTimeField(auto_now_add=True, db_index=True)
ngay_cap_nhat = models.DateTimeField(auto_now=True)
ngay_xuat_ban = models.DateTimeField(null=True, blank=True)

class Meta:
verbose_name = "Bài viết"
verbose_name_plural = "Bài viết"
ordering = ["-ngay_tao"]
indexes = [
models.Index(fields=["trang_thai", "-ngay_tao"]),
models.Index(fields=["tac_gia", "trang_thai"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(luot_xem__gte=0),
name="luot_xem_khong_am",
),
]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.tieu_de, allow_unicode=True)[:260]
if self.trang_thai == "xuat_ban" and self.ngay_xuat_ban is None:
self.ngay_xuat_ban = timezone.now()
super().save(*args, **kwargs)

def get_absolute_url(self):
return reverse("blog:chi_tiet", kwargs={"slug": self.slug})

@property
def da_xuat_ban(self):
return self.trang_thai == "xuat_ban"

def __str__(self):
return self.tieu_de


class The(models.Model):
ten = models.CharField(max_length=60, unique=True)
slug = models.SlugField(max_length=80, unique=True)

def __str__(self):
return self.ten

Năm quyết định thiết kế quan trọng trong đoạn code trên:

Quyết địnhLý do
on_delete=models.CASCADE cho tác giảXoá user → xoá bài của họ
on_delete=models.SET_NULL cho danh mụcXoá danh mục không nên xoá bài
db_index=True cho slug, trang_thai, ngay_taoCác trường hay lọc/sắp xếp
indexes tổ hợpQuery thật luôn lọc theo nhiều cột
CheckConstraintRàng buộc logic ngay ở tầng database

⚠️ Tuyệt đối không dùng FloatField cho tiền. Luôn DecimalField. Sai số dấu phẩy động có thể làm lệch sổ sách vài đồng — và trong tài chính, đó là lỗi không thể chấp nhận.

Migration — quy trình bất di bất dịch​

python manage.py makemigrations blog
python manage.py migrate
python manage.py showmigrations blog
python manage.py sqlmigrate blog 0001 # xem SQL thật sẽ chạy

Ba quy tắc:

  1. Không bao giờ sửa file trong migrations/ bằng tay.
  2. Luôn sqlmigrate để xem SQL trước khi migrate production.
  3. Đổi tên cột cần migration đặc biệt — Django hiểu là xoá + tạo mới → mất dữ liệu.

ORM — bảng tra cứu cần thuộc​

from django.db.models import Q, F, Count, Sum, Avg, Max, Case, When, Value, IntegerField

# --- Lọc ---
BaiViet.objects.filter(trang_thai="xuat_ban")
BaiViet.objects.filter(luot_xem__gte=1000)
BaiViet.objects.filter(tieu_de__icontains="python")
BaiViet.objects.filter(danh_muc__isnull=True)
BaiViet.objects.filter(ngay_tao__year=2026, ngay_tao__month=10)
BaiViet.objects.filter(Q(trang_thai="xuat_ban") | Q(luot_xem__gt=5000))
BaiViet.objects.exclude(trang_thai="nhap")

# --- Sắp xếp & giới hạn ---
BaiViet.objects.order_by("-luot_xem")[:10]
BaiViet.objects.order_by(F("luot_xem").desc(nulls_last=True))

# --- Truy vấn liên bảng ---
BaiViet.objects.filter(the__ten="python").distinct()
BaiViet.objects.filter(danh_muc__slug="huong-dan")

# --- Aggregate ---
BaiViet.objects.aggregate(tong_luot_xem=Sum("luot_xem"), tb=Avg("luot_xem"))

# --- Annotate (tính toán trên từng bản ghi) ---
DanhMuc.objects.annotate(so_bai=Count("bai_viet")).filter(so_bai__gt=0)

# --- Cập nhật bằng F() — tránh race condition ---
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

# --- bulk_create / bulk_update — nhanh hơn nhiều lần ---
BaiViet.objects.bulk_create([BaiViet(tieu_de=f"Bài {i}") for i in range(1000)], batch_size=500)

F() — điều mà người mới luôn làm sai:

# ❌ SAI — đọc rồi ghi, có race condition
bv = BaiViet.objects.get(pk=1)
bv.luot_xem = bv.luot_xem + 1 # hai request đồng thời → mất 1 lượt
bv.save()

# ✅ ĐÚNG — database tự tăng, atomic
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

Django Admin​

# blog/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import BaiViet, DanhMuc, The


@admin.register(DanhMuc)
class DanhMucAdmin(admin.ModelAdmin):
list_display = ("ten", "slug", "so_bai_viet")
search_fields = ("ten",)
prepopulated_fields = {"slug": ("ten",)}

@admin.display(description="Số bài viết")
def so_bai_viet(self, obj):
return obj.bai_viet.count()


@admin.register(BaiViet)
class BaiVietAdmin(admin.ModelAdmin):
list_display = ("tieu_de", "tac_gia", "danh_muc", "trang_thai_badge", "luot_xem", "ngay_tao")
list_filter = ("trang_thai", "danh_muc", "ngay_tao")
search_fields = ("tieu_de", "noi_dung", "tac_gia__username")
prepopulated_fields = {"slug": ("tieu_de",)}
date_hierarchy = "ngay_tao"
autocomplete_fields = ("tac_gia", "danh_muc")
filter_horizontal = ("the",)
list_select_related = ("tac_gia", "danh_muc") # ← tránh N+1 trong admin
readonly_fields = ("luot_xem", "ngay_tao", "ngay_cap_nhat")
list_per_page = 25
actions = ["duyet_hang_loat"]

fieldsets = (
("Nội dung", {"fields": ("tieu_de", "slug", "tom_tat", "noi_dung")}),
("Phân loại", {"fields": ("danh_muc", "the")}),
("Xuất bản", {"fields": ("tac_gia", "trang_thai", "ngay_xuat_ban")}),
("Thống kê", {"fields": ("luot_xem", "ngay_tao", "ngay_cap_nhat"), "classes": ("collapse",)}),
)

@admin.display(description="Trạng thái", ordering="trang_thai")
def trang_thai_badge(self, obj):
mau = {"nhap": "#6b7280", "cho_duyet": "#f59e0b", "xuat_ban": "#16a34a"}[obj.trang_thai]
return format_html(
'<span style="background:{};color:#fff;padding:2px 8px;border-radius:10px">{}</span>',
mau, obj.get_trang_thai_display(),
)

@admin.action(description="Duyệt và xuất bản các bài đã chọn")
def duyet_hang_loat(self, request, queryset):
so_luong = queryset.update(trang_thai="xuat_ban")
self.message_user(request, f"Đã xuất bản {so_luong} bài viết.")

Đoạn này cho bạn: badge màu theo trạng thái, action hàng loạt, fieldsets gọn gàng, và list_select_related để admin không chạy N+1 query.


📝 Tuần 8–9: Form, Class-based View, xác thực​

# blog/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import BaiViet


class BaiVietForm(forms.ModelForm):
class Meta:
model = BaiViet
fields = ["tieu_de", "tom_tat", "noi_dung", "danh_muc", "the", "trang_thai"]
widgets = {
"tom_tat": forms.Textarea(attrs={"rows": 3, "maxlength": 500}),
"noi_dung": forms.Textarea(attrs={"rows": 20}),
}
help_texts = {"tom_tat": "Tối đa 500 ký tự, hiển thị ở trang danh sách."}

def clean_tieu_de(self):
tieu_de = self.cleaned_data["tieu_de"].strip()
if len(tieu_de) < 10:
raise ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return tieu_de

def clean(self):
du_lieu = super().clean()
if du_lieu.get("trang_thai") == "xuat_ban" and not du_lieu.get("tom_tat"):
self.add_error("tom_tat", "Bài xuất bản phải có tóm tắt.")
return du_lieu

Class-based View — dùng khi nào?

Loại viewDùng choVí dụ
TemplateViewTrang tĩnhGiới thiệu, liên hệ
ListViewDanh sách có phân trangDanh sách bài viết
DetailViewChi tiết một bản ghiChi tiết bài viết
CreateViewForm tạo mớiTạo bài viết
UpdateViewForm cập nhậtSửa bài viết
DeleteViewXác nhận xoáXoá bài viết
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy


class DanhSachBaiVietView(ListView):
model = BaiViet
template_name = "blog/danh_sach.html"
context_object_name = "bai_viet"
paginate_by = 12

def get_queryset(self):
qs = BaiViet.objects.filter(trang_thai="xuat_ban").select_related("tac_gia", "danh_muc")
if danh_muc := self.request.GET.get("danh_muc"):
qs = qs.filter(danh_muc__slug=danh_muc)
if tu_khoa := self.request.GET.get("q"):
qs = qs.filter(tieu_de__icontains=tu_khoa)
return qs


class TaoBaiVietView(LoginRequiredMixin, CreateView):
model = BaiViet
form_class = BaiVietForm
template_name = "blog/form.html"
success_url = reverse_lazy("blog:danh_sach")

def form_valid(self, form):
form.instance.tac_gia = self.request.user
return super().form_valid(form)

💡 Kinh nghiệm thực tế: dùng ListView/DetailView vì chúng tiết kiệm nhiều code. Nhưng khi logic phình ra, quay về function view — dễ đọc hơn. Đừng cố nhồi mọi thứ vào generic view.


🔌 Tuần 10–11: Django REST Framework​

pip install djangorestframework djangorestframework-simplejwt django-filter
# blog/serializers.py
from rest_framework import serializers
from .models import BaiViet, DanhMuc


class DanhMucSerializer(serializers.ModelSerializer):
so_bai_viet = serializers.IntegerField(read_only=True)

class Meta:
model = DanhMuc
fields = ["id", "ten", "slug", "so_bai_viet"]


class BaiVietListSerializer(serializers.ModelSerializer):
tac_gia_ten = serializers.CharField(source="tac_gia.username", read_only=True)
danh_muc_ten = serializers.CharField(source="danh_muc.ten", read_only=True, default=None)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "tac_gia_ten",
"danh_muc_ten", "luot_xem", "ngay_xuat_ban"]


class BaiVietDetailSerializer(serializers.ModelSerializer):
tac_gia = serializers.StringRelatedField(read_only=True)
danh_muc = DanhMucSerializer(read_only=True)
danh_muc_id = serializers.PrimaryKeyRelatedField(
queryset=DanhMuc.objects.all(), source="danh_muc", write_only=True,
required=False, allow_null=True,
)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "noi_dung",
"tac_gia", "danh_muc", "danh_muc_id", "the",
"trang_thai", "luot_xem", "ngay_tao", "ngay_xuat_ban"]
read_only_fields = ["slug", "luot_xem", "ngay_tao", "ngay_xuat_ban"]

def validate_tieu_de(self, value):
if len(value.strip()) < 10:
raise serializers.ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return value.strip()
# blog/api_views.py
from django.db.models import Count, Q
from rest_framework import viewsets, filters, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import F

from .models import BaiViet, DanhMuc
from .serializers import BaiVietListSerializer, BaiVietDetailSerializer, DanhMucSerializer


class BaiVietViewSet(viewsets.ModelViewSet):
queryset = (
BaiViet.objects
.select_related("tac_gia", "danh_muc")
.prefetch_related("the")
)
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["trang_thai", "danh_muc"]
search_fields = ["tieu_de", "tom_tat", "noi_dung"]
ordering_fields = ["ngay_tao", "luot_xem"]
ordering = ["-ngay_tao"]
lookup_field = "slug"

def get_serializer_class(self):
if self.action == "list":
return BaiVietListSerializer
return BaiVietDetailSerializer

def get_permissions(self):
if self.action in ("list", "retrieve"):
return [permissions.AllowAny()]
return [permissions.IsAuthenticated()]

def perform_create(self, serializer):
serializer.save(tac_gia=self.request.user)

@action(detail=True, methods=["post"], permission_classes=[permissions.AllowAny])
def tang_luot_xem(self, request, slug=None):
BaiViet.objects.filter(slug=slug).update(luot_xem=F("luot_xem") + 1)
bai_viet = self.get_object()
return Response({"luot_xem": bai_viet.luot_xem}, status=status.HTTP_200_OK)


class DanhMucViewSet(viewsets.ReadOnlyModelViewSet):
queryset = DanhMuc.objects.annotate(so_bai_viet=Count("bai_viet", filter=Q(bai_viet__trang_thai="xuat_ban")))
serializer_class = DanhMucSerializer
lookup_field = "slug"
# blog/api_urls.py
from rest_framework.routers import DefaultRouter
from .api_views import BaiVietViewSet, DanhMucViewSet

router = DefaultRouter()
router.register("bai-viet", BaiVietViewSet, basename="bai-viet")
router.register("danh-muc", DanhMucViewSet, basename="danh-muc")

urlpatterns = router.urls

Những gì bạn vừa có mà không phải viết:

  • CRUD đầy đủ cho /api/bai-viet/ và /api/danh-muc/.
  • Tìm kiếm ?search=python, lọc ?trang_thai=xuat_ban, sắp xếp ?ordering=-luot_xem.
  • Phân trang tự động.
  • Endpoint tuỳ chỉnh POST /api/bai-viet/{slug}/tang_luot_xem/.
  • Giao diện thử API tại /api/ trong browser.

Cấu hình DRF trong settings.py:

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"anon": "60/min", "user": "2000/hour"},
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}

⚡ Tuần 12: Testing và tối ưu truy vấn​

Testing​

# blog/tests/test_api.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient

from blog.models import BaiViet


class BaiVietAPITest(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("minh", password="matkhau123")
self.bai_viet = BaiViet.objects.create(
tieu_de="Hướng dẫn Django cơ bản",
noi_dung="Nội dung...",
tac_gia=self.user,
trang_thai="xuat_ban",
)

def test_danh_sach_khong_can_dang_nhap(self):
r = self.client.get(reverse("bai-viet-list"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)

def test_tao_bai_viet_can_dang_nhap(self):
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Bài mới"})
self.assertEqual(r.status_code, 401)

def test_tao_bai_viet_thanh_cong(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {
"tieu_de": "Bài viết hoàn toàn mới",
"noi_dung": "Nội dung",
"trang_thai": "nhap",
})
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["tac_gia"]["id"], self.user.id)

def test_tieu_de_qua_ngan(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Ngắn"})
self.assertEqual(r.status_code, 400)
self.assertIn("tieu_de", r.data)

def test_tang_luot_xem(self):
url = reverse("bai-viet-tang-luot-xem", kwargs={"slug": self.bai_viet.slug})
self.client.post(url)
self.client.post(url)
self.bai_viet.refresh_from_db()
self.assertEqual(self.bai_viet.luot_xem, 2)
python manage.py test --parallel
coverage run --source='.' manage.py test && coverage report

Tối ưu N+1 — vấn đề hiệu năng số 1 của Django​

# ❌ N+1: 1 query lấy 100 bài + 100 query lấy tác giả = 101 query
for bv in BaiViet.objects.all():
print(bv.tac_gia.username) # mỗi lần là 1 query!

# ✅ 1 query duy nhất với JOIN
for bv in BaiViet.objects.select_related("tac_gia", "danh_muc"):
print(bv.tac_gia.username)

# ❌ N+1 với ManyToMany: 1 + 100 query
for bv in BaiViet.objects.all():
print([t.ten for t in bv.the.all()])

# ✅ prefetch_related: 2 query tổng cộng
for bv in BaiViet.objects.prefetch_related("the"):
print([t.ten for t in bv.the.all()])
HàmDùng choSố query
select_relatedForeignKey, OneToOne1 (JOIN)
prefetch_relatedManyToMany, quan hệ ngược2
Prefetchprefetch có lọc2

Công cụ phát hiện N+1:

# Cài django-debug-toolbar hoặc dùng assertNumQueries trong test
from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
list(BaiViet.objects.select_related("tac_gia").all()[:20])
print(f"Số query: {len(ctx)}") # phải là 1, không phải 21

Cache:

from django.core.cache import cache
from django.views.decorators.cache import cache_page


@cache_page(60 * 15) # cache view 15 phút
def danh_sach_bai_viet(request):
...


def lay_bai_viet_noi_bat():
khoa = "bai_viet_noi_bat"
du_lieu = cache.get(khoa)
if du_lieu is None:
du_lieu = list(BaiViet.objects.filter(trang_thai="xuat_ban").order_by("-luot_xem")[:10])
cache.set(khoa, du_lieu, timeout=300)
return du_lieu

⏰ Tuần 13: Celery, tác vụ nền và signal​

pip install celery redis
# config/celery.py
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# blog/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.utils import timezone
from datetime import timedelta


@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def gui_email_thong_bao(self, bai_viet_id: int):
from .models import BaiViet
try:
bv = BaiViet.objects.select_related("tac_gia").get(pk=bai_viet_id)
send_mail(
subject=f"Bài viết đã xuất bản: {bv.tieu_de}",
message=f"Bài viết của bạn đã được xuất bản: {bv.get_absolute_url()}",
from_email="noreply@example.com",
recipient_list=[bv.tac_gia.email],
fail_silently=False,
)
return f"Đã gửi email cho {bv.tac_gia.email}"
except Exception as exc:
raise self.retry(exc=exc)


@shared_task
def don_dep_ban_nhap_cu():
"""Xoá bản nháp không cập nhật trong 90 ngày."""
from .models import BaiViet
han = timezone.now() - timedelta(days=90)
so_luong, _ = BaiViet.objects.filter(trang_thai="nhap", ngay_cap_nhat__lt=han).delete()
return f"Đã xoá {so_luong} bản nháp cũ"
# Chạy worker (terminal riêng)
celery -A config worker -l info

# Chạy scheduler cho task định kỳ
celery -A config beat -l info

⚠️ Sai lầm phổ biến với Celery: truyền object model vào task thay vì id. Khi đó object bị serialize vào queue và có thể đã cũ hoặc không deserialize được. Luôn truyền id rồi truy vấn lại trong task.


🐳 Tuần 14–15: Docker và deploy​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn

COPY . .

RUN python manage.py collectstatic --noinput
RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", \
"--workers", "4", "--timeout", "60", "--access-logfile", "-"]

settings.py cho production:

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # bắt buộc, không có mặc định
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60, # tái sử dụng kết nối
"OPTIONS": {"connect_timeout": 10},
}
}

# Bảo mật production — bật hết
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/1"),
}
}

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"day_du": {"format": "{asctime} | {levelname:<8} | {name} | {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "day_du"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
# Migration trong pipeline deploy — không chạy tay
python manage.py migrate --noinput
python manage.py collectstatic --noinput

Checklist bảo mật Django trước khi lên production:

python manage.py check --deploy
  • DEBUG = False
  • SECRET_KEY từ env, đủ dài và ngẫu nhiên
  • ALLOWED_HOSTS khai báo rõ ràng
  • HTTPS bắt buộc + HSTS
  • Cookie Secure, HttpOnly, SameSite
  • CSRF_TRUSTED_ORIGINS cấu hình đúng
  • Backup database tự động + test khả năng phục hồi
  • Log tập trung, có cảnh báo lỗi 500

🎯 Tuần 16: Dự án tổng hợp​

Hệ thống quản lý nội dung có API — tận dụng tối đa thế mạnh của Django (admin) và DRF (API):

✅ Model đầy đủ quan hệ: FK, M2M, index, constraint
✅ Django Admin cấu hình đầy đủ (filter, search, action, badge)
✅ REST API với DRF: viewset, serializer lồng nhau, phân trang
✅ JWT auth + phân quyền theo vai trò
✅ Celery gửi email khi xuất bản bài
✅ Tối ưu: select_related, prefetch_related, cache Redis
✅ Test coverage > 70%
✅ Docker + docker-compose (web + postgres + redis + celery)
✅ Deploy có link thật, `check --deploy` sạch

🔍 Debug Django trong thực tế​

Debug là kỹ năng bạn dùng hàng ngày, nhưng hầu như không khoá học nào dạy.

Django shell — công cụ mạnh nhất​

python manage.py shell
>>> from blog.models import BaiViet
>>> from django.db import connection, reset_queries
>>> from django.conf import settings
>>> settings.DEBUG = True

>>> # Đếm số query của một đoạn code
>>> reset_queries()
>>> ds = list(BaiViet.objects.filter(trang_thai="xuat_ban"))
>>> [(b.tieu_de, b.tac_gia.username) for b in ds]
>>> print(f"Số query: {len(connection.queries)}")

>>> # Xem SQL thật
>>> print(BaiViet.objects.filter(trang_thai="xuat_ban").query)

>>> # Kiểm tra dữ liệu bẩn
>>> BaiViet.objects.filter(tom_tat="").count()
>>> BaiViet.objects.filter(ngay_xuat_ban__gt=timezone.now()).count()

💡 Mẹo: cài django-extensions rồi dùng python manage.py shell_plus — tự import toàn bộ model, tiết kiệm rất nhiều thời gian.

django-debug-toolbar — bắt N+1 ngay trên trình duyệt​

pip install django-debug-toolbar
# settings.py (chỉ bật khi DEBUG)
if DEBUG:
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]

# urls.py
if settings.DEBUG:
import debug_toolbar
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]

Bảng SQL của toolbar hiển thị mọi truy vấn và thời gian. Nếu thấy 50 truy vấn giống nhau trên một trang, bạn đã gặp N+1.

Logging — biết chuyện gì đang xảy ra trên production​

import logging

logger = logging.getLogger(__name__)


def xuat_ban_bai_viet(bai_viet_id: int) -> bool:
from .models import BaiViet

logger.info("Bắt đầu xuất bản bài viết id=%s", bai_viet_id)

try:
bv = BaiViet.objects.get(pk=bai_viet_id)
except BaiViet.DoesNotExist:
logger.warning("Không tìm thấy bài viết id=%s", bai_viet_id)
return False

if bv.trang_thai == "xuat_ban":
logger.info("Bài viết id=%s đã xuất bản trước đó, bỏ qua", bai_viet_id)
return True

try:
with transaction.atomic():
bv.trang_thai = "xuat_ban"
bv.ngay_xuat_ban = timezone.now()
bv.save(update_fields=["trang_thai", "ngay_xuat_ban"])
except Exception:
logger.exception("Lỗi khi xuất bản bài viết id=%s", bai_viet_id)
raise

logger.info("Đã xuất bản bài viết id=%s", bai_viet_id)
return True

Ba cấp độ log và cách dùng đúng:

Cấp độDùng khiVí dụ
DEBUGChi tiết chỉ cần khi phát triểnGiá trị biến trung gian
INFOSự kiện bình thường, quan trọng"Đã xuất bản bài viết 42"
WARNINGBất thường nhưng hệ thống vẫn chạy"Không tìm thấy bài viết"
ERRORThao tác thất bại"Không gửi được email"
exception()Lỗi có tracebackTrong khối except

⚠️ Dùng logger.info("id=%s", id) thay vì logger.info(f"id={id}") — cách đầu chỉ format chuỗi khi log thực sự được ghi, tiết kiệm CPU khi log bị tắt.

Bảng lỗi Django hay gặp​

LỗiNguyên nhânCách sửa
no such tableChưa migratepython manage.py migrate
Table already existsMigration lệch trạng thái--fake-initial rồi kiểm tra showmigrations
TemplateDoesNotExistSai cấu trúc thư mụcCần app/templates/app/file.html
AppRegistryNotReadyImport model quá sớmImport trong hàm
Reverse for 'x' not foundSai name= hoặc thiếu app_nameKiểm tra urls.py
RelatedObjectDoesNotExistTruy cập OneToOne chưa cóDùng hasattr() hoặc try/except
Migration conflict khi làm nhómHai người tạo migration cùng sốmakemigrations --merge
Maximum recursion depthSignal gọi save() trong save()Dùng update() hoặc cờ _dang_luu

📦 Quản lý phụ thuộc và cấu hình môi trường​

Tách requirements theo môi trường​

requirements/
├── base.txt ← dùng chung
├── dev.txt ← thêm cho phát triển
└── prod.txt ← chỉ cho production

base.txt:

Django==5.1.*
djangorestframework==3.15.*
django-filter==24.*
psycopg[binary]==3.2.*
python-dotenv==1.0.*

dev.txt:

-r base.txt
pytest==8.*
pytest-django==4.*
coverage==7.*
django-debug-toolbar==4.*
ruff==0.6.*
black==24.*
factory-boy==3.*

prod.txt:

-r base.txt
gunicorn==23.*
whitenoise==6.*
sentry-sdk==2.*
django-redis==5.*

Cách này giữ image production nhỏ (không mang theo pytest, debug toolbar) và giảm rủi ro bảo mật.

Đọc cấu hình bằng environ​

# settings.py
import os
from pathlib import Path

def env_bool(ten: str, mac_dinh: bool = False) -> bool:
return os.environ.get(ten, str(mac_dinh)).lower() in ("1", "true", "yes", "on")


def env_list(ten: str, mac_dinh: list[str] | None = None) -> list[str]:
gia_tri = os.environ.get(ten, "")
return [x.strip() for x in gia_tri.split(",") if x.strip()] or (mac_dinh or [])


SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = env_bool("DJANGO_DEBUG")
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", ["localhost"])
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS")

if not DEBUG and SECRET_KEY.startswith("django-insecure"):
raise RuntimeError("SECRET_KEY không an toàn đang được dùng ở production!")

💡 Kiểm tra if not DEBUG này gây crash ngay khi khởi động. Đó là điều bạn muốn — tốt hơn nhiều so với việc phát hiện ra sau khi bị tấn công.

Quản lý file tĩnh và media​

LoạiLà gìPhục vụ bởi
staticCSS, JS, ảnh của bạnnginx, whitenoise, CDN
mediaFile người dùng uploadnginx, S3, Cloudinary
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

⚠️ Django không phục vụ file tĩnh ở production. runserver làm được điều đó, nhưng đó chỉ là tính năng của môi trường phát triển. Production phải dùng nginx/whitenoise/CDN — nếu không, hiệu năng sẽ rất tệ.


🤝 Làm việc nhóm trong dự án Django​

Quy ước code — bắt buộc phải tự động hoá​

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # bugbear — bắt lỗi logic phổ biến
"DJ", # flake8-django
"S", # bandit — bảo mật
]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"**/settings*.py" = ["F405"]
"**/migrations/*" = ["E501", "S"]
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=1000"]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
pip install pre-commit
pre-commit install

Từ giờ mọi commit đều tự động kiểm tra và format. Đây là việc quan trọng nhất bạn có thể làm trong ngày đầu tiên ở bất kỳ dự án nào.

Xử lý conflict migration khi làm nhóm​

Đây là vấn đề khiến nhiều người mất hàng giờ:

Hai người cùng tạo migration sau 0005_moi.py:
Người A: 0006_them_truong_a.py
Người B: 0006_them_truong_b.py ← trùng số!

Quy trình xử lý:

# 1. Pull code mới nhất
# 2. Django sẽ báo conflict hoặc tạo 2 migration cùng tên
# 3. Chạy merge để gộp thành một nhánh tuyến tính
python manage.py makemigrations --merge --no-input

# 4. Kiểm tra lại
python manage.py showmigrations app
python manage.py migrate --plan

Ba quy tắc phòng ngừa:

  1. Luôn tạo migration ngay sau khi sửa model, đừng để tích tụ.
  2. Test migration cả hai chiều trên database copy: migrate app 0001 rồi migrate app.
  3. Không bao giờ sửa migration đã được merge vào main. Nếu sai, tạo migration mới để sửa.

Migration có rủi ro mất dữ liệu — phải làm theo 2 bước​

Đổi tên cột trực tiếp sẽ khiến Django hiểu là xoá cột cũ và tạo cột mới:

# BƯỚC 1: thêm cột mới, giữ cột cũ
class BaiViet(models.Model):
tieu_de_moi = models.CharField(max_length=250, null=True) # cho phép null tạm

# BƯỚC 2: sau khi deploy và migrate, chạy data migration copy dữ liệu
# migrations/0008_copy_tieu_de.py
def copy_du_lieu(apps, schema_editor):
BaiViet = apps.get_model("blog", "BaiViet")
BaiViet.objects.update(tieu_de_moi=models.F("tieu_de"))

# BƯỚC 3 (deploy sau): xoá cột cũ

Với dự án lớn, mỗi bước là một lần deploy riêng. Với bảng 50 triệu dòng, ALTER TABLE có thể khoá bảng vài phút — cần kế hoạch bảo trì.


⚠️ 8 sai lầm khiến dự án Django khó bảo trì​

  1. Logic nghiệp vụ trong view. Tách ra services.py.
  2. Fat model với 2000 dòng. Chia thành mixin hoặc module riêng.
  3. Không tối ưu truy vấn. N+1 là nguyên nhân làm chậm số 1.
  4. Dùng FloatField cho tiền. Dùng DecimalField.
  5. Đọc rồi ghi thay vì dùng F(). Race condition.
  6. Không dùng migration cho thay đổi schema. create_all() chỉ có trong tutorial.
  7. Bỏ qua admin.py. Bạn đang vứt đi lợi thế lớn nhất của Django.
  8. Không viết test. Model phình ra, không ai dám sửa.

❓ Câu hỏi thường gặp​

Django có phù hợp cho API thuần không? Có, DRF rất mạnh. Nhưng nếu bạn chỉ cần API và không dùng admin/ORM template, FastAPI thường gọn hơn.

Nên học Django hay DRF trước? Django trước. DRF dựa hoàn toàn trên ORM và model của Django. Học DRF khi chưa hiểu ORM là công thức của việc copy-paste.

Django có hỗ trợ async không? Có (từ 3.1) cho view và ORM (từ 4.1), nhưng hệ sinh thái chưa async hoàn toàn. Nếu async là yêu cầu trung tâm, FastAPI phù hợp hơn.

Django Admin có dùng cho khách hàng được không? Không nên. Admin là công cụ nội bộ. Khách hàng cần giao diện riêng. Dùng admin cho nhân viên vận hành, không cho người dùng cuối.

Bao lâu để đi làm với Django? Nếu đã biết Python + SQL: 4–6 tháng. Nếu học từ đầu: 9–12 tháng.


📚 Bài viết liên quan​