সরাসরি পর্বে যান
পর্ব ৬৮ · ৯৩-এর মধ্যে · মডিউল ১০

Django — batteries included, ORM, admin আর MVT

Django — Batteries Included, the ORM, the Admin and MVT

Taught in Bangla, this lesson introduces Django for JavaScript developers: batteries included, MVT as another name for web MVC, models and lazy QuerySets, fixing N+1 with select_related and prefetch_related, migrations, views, URLconf and forms, template autoescaping and its string-literal trap shown in a real run, the built-in admin, login, sessions and CSRF, APIs, async, settings and deployment checks with check --deploy, and when Django fits.

এই পর্বে যা শিখবেন

  • Django-র project আর app কী, আর MVT-কে Express ও React-এর পরিচিত ভাষায় পড়া
  • Model, lazy QuerySet, select_related আর prefetch_related দিয়ে query সংখ্যা মাপা ও কমানো
  • Model বদলে migration বানানো, তার SQL পড়া আর চালানো
  • URLconf, function ও class-based view, template, form আর validation দিয়ে বাজারঘরের পণ্যের অংশ বানানো
  • Admin, login, session আর CSRF-এর আসল আচরণ দেখা, আর production settings ঠিক করা

১ · Django কী — নিউজরুমের তাড়া থেকে “batteries included”

এতক্ষণ আমরা বাজারঘর বানিয়েছি JavaScript-এর জগতে: React সামনে, Express আর Prisma পেছনে। প্রতিটি কাজের জন্য একটি করে package বেছে নিয়েছি: validation-এ Zod, login-এ session store, ORM-এ Prisma. এই পর্বে একই দোকানের একটি ছোট অংশ বানাব Python-এর একটি বহুল পরিচিত web framework Django দিয়ে। Python ভাষাটি নিজে শিখতে চাইলে আমাদের Python কোর্স আছে। এখানে ভাষা নয়, framework-এর চিন্তা দেখব।

দুই রকম রান্নাঘর ভাবুন। একটিতে শুধু চুলা আছে; হাঁড়ি, ছুরি, মসলা আপনি নিজে কিনে আনবেন। যা খুশি রাঁধতে পারবেন, কিন্তু প্রথম দিনেই বাজারে যেতে হবে। Express এমন। আরেকটি হোটেলের পূর্ণ রান্নাঘর: হাঁড়ি, তাক, মসলা, ফ্রিজ সব সাজানো, কোনটি কোথায় থাকবে তাও ঠিক করা। প্রথম দিনেই রান্না শুরু, তবে তাক নিজের মতো সাজাতে চাইলে নিয়মের সাথে লড়তে হয়। Django দ্বিতীয়টি। একে বলে batteries included: খেলনার সাথে ব্যাটারিও বাক্সে দেওয়া।

Django-র দাপ্তরিক FAQ অনুযায়ী এর জন্ম যুক্তরাষ্ট্রের Kansas রাজ্যের Lawrence শহরের একটি সংবাদপত্রের ওয়েব বিভাগে (World Online, Lawrence Journal-World-এর ওয়েব দল)। ২০০৩ সালের শরতে Adrian Holovaty আর Simon Willison PHP ছেড়ে Python-এ সাইট বানানো শুরু করেন। সংবাদের deadline-এ দ্রুত পাতা বানানোর চাপ থেকেই ভেতরের টুলগুলো জমে ওঠে। ২০০৫ সালের গ্রীষ্মে সেগুলো open source হিসেবে প্রকাশ পায়, নাম রাখা হয় জ্যাজ গিটারবাদক Django Reinhardt-এর নামে। সংস্করণ ১.০ আসে ২০০৮ সালে। প্রকাশের দিন-তারিখের খুঁটিনাটি নিয়ে উৎসভেদে সামান্য পার্থক্য থাকতে পারে।

নিউজরুমের এই উৎস Django-র চরিত্র বোঝায়। সংবাদপত্রে সম্পাদকরা প্রতিদিন লেখা যোগ করেন, তাই admin পাতা দরকার। অনেক ধরনের কনটেন্ট, তাই শক্তিশালী ORM দরকার। Deadline আছে, তাই সব একসাথে থাকা দরকার। অনেকের মতে আজও Django সবচেয়ে উজ্জ্বল এই ধরনের কাজে।

Project আর app

Django-তে দুটি শব্দ শুরুতেই গুলিয়ে যায়। Project হলো পুরো ওয়েবসাইট: settings, মূল URL তালিকা আর server-এর প্রবেশদ্বার। App হলো একটি নির্দিষ্ট কাজের অংশ, যেমন shop, blog বা accounts, যার নিজের model, view আর template থাকে। একটি project-এ অনেক app থাকে। Admin, auth আর session-ও আসলে Django-র নিজের app. একটি বড় শপিং মলকে project ভাবুন, আর প্রতিটি দোকানকে app.

সত্যিকারের project · Django 6.1.2, Python 3.14.4, SQLite · তৈরি django-admin startproject bazarghar আর python manage.py startapp shop দিয়ে · ~/bazarghar

bazarghar/
├── manage.py              # কমান্ডের দরজা: runserver, migrate, test …
├── db.sqlite3
├── bazarghar/             # project: সাইটের সাধারণ অংশ
│   ├── settings.py        # INSTALLED_APPS, MIDDLEWARE, DATABASES …
│   ├── urls.py            # মূল URL তালিকা
│   ├── wsgi.py / asgi.py  # server-এর প্রবেশদ্বার
└── shop/                  # app: বাজারঘরের পণ্য
    ├── models.py  views.py  urls.py  admin.py  tests.py
    ├── migrations/0001_initial.py
    └── templates/shop/products.html

লেখার সময়ে PyPI-তে Django-র সর্বশেষ সংস্করণ 6.1.2, যা Python 3.12 বা তার নতুন চায়। Django সাধারণত প্রায় আট মাস পরপর একটি feature release দেয়, আর কয়েক সংস্করণ পরপর একটি দীর্ঘমেয়াদি সহায়তার (LTS) সংস্করণ। লেখার সময়ে সর্বশেষ LTS ছিল 5.2. নতুন project-এ কোনটি নেবেন, তা Django-র ওয়েবসাইটের “supported versions” তালিকা দেখে ঠিক করুন।

মূল ধারণা

Express-এ আপনি ফ্রেমওয়ার্ককে ডাকেন, Django-তে ফ্রেমওয়ার্ক আপনাকে ডাকে। আপনি নির্দিষ্ট নামের ফাইলে (models.py, urls.py, admin.py) নির্দিষ্ট আকারের কোড রাখেন, আর Django সেগুলো খুঁজে নিয়ে জোড়ে। পর্ব ০৪-এ (Architecture pattern) যাকে inversion of control বলেছি, Django তার একটি পরিষ্কার উদাহরণ।

২ · MVT — Express আর React-এর চোখে

পর্ব ০৪-এ MVC আর MVT নাম হিসেবে দেখেছেন। এবার কাজে দেখি। Django-র তিন ভাগ: Model (ডেটা আর তার নিয়ম), View (একটি URL-এর জন্য Python function, যা ঠিক করে কোন ডেটা দেখানো হবে), আর Template (সেই ডেটা দেখতে কেমন হবে)। তাহলে controller কোথায়? Django-র FAQ নিজেই বলে, তাদের ব্যাখ্যায় controller সম্ভবত framework নিজে: যে যন্ত্র URL দেখে request-কে সঠিক view-তে পাঠায়।

Django-র MVT আর Express ও React-এর মিল

One request, left to right; the grey labels show the closest idea you already know

Django-র MVT: request থেকে response Browser · GET /products/ Middleware URLconf · urls.py View · views.py কোন ডেটা দেখাবে Model ORM Template দেখতে কেমন Database app.use(...) Express router route handler (controller) JSX / view Prisma-র মতো “controller” = framework নিজে
ছবি ৬৮.১ — একটি request middleware আর URLconf পেরিয়ে view-তে আসে। View model থেকে ডেটা নেয়, template দিয়ে HTML বানায়, আর response একই middleware হয়ে উল্টো পথে ফেরে।
DjangoExpress / Prisma / React-এ কাছের জিনিসলক্ষণীয় পার্থক্য
urls.py-এর path()router.get('/products/:id')তালিকা আলাদা ফাইলে, view-এর পাশে নয়
View functionRoute handler (req, res)response লেখেন না, ফেরত দেন
models.pyschema.prismaModel Python class, migration তৈরি হয় এখান থেকে
Template ({{ }}, {% %})Server-এ JSX বা EJSServer-এ HTML বানায়; browser-এ state নেই
MIDDLEWARE তালিকাapp.use() chainSession, CSRF, auth ডিফল্টে চালু
Adminনিজে বানাতে হয়Model থেকে নিজেই তৈরি

লক্ষ করুন, Django-র template server-এ চলে, পাতা তৈরি করে পাঠায়। এটি পর্ব ০৩-এর (Rendering-এর মানচিত্র) SSR, React-এর মতো browser-এ UI ধরে রাখা নয়। অনেক দল তাই Django-কে শুধু API হিসেবে চালায়, আর সামনে React বা Next.js রাখে। দুটি পথই প্রচলিত।

নিচে বাজারঘরের /products/ পাতার একটি request ধাপে ধাপে দেখুন। প্রতিটি কোড আর SQL আমাদের আসল project থেকে।

  1. BrowserGET/products/
  2. Middleware৭টিক্রমে
  3. URLconfpath()মিলানো
  4. Viewproduct_pageQuerySet
  5. ORM১টি SQLJOIN
  6. TemplateHTMLescape
Browserrequest
GET /products/ HTTP/1.1
Host: 127.0.0.1:8124
settings.pyওপর থেকে নিচে, ফেরার পথে উল্টো
SecurityMiddleware
SessionMiddleware       # cookie → request.session
CommonMiddleware
CsrfViewMiddleware      # POST হলে token যাচাই
AuthenticationMiddleware # request.user
MessageMiddleware
XFrameOptionsMiddleware
URLconfপ্রথম মিলেই থামে
# bazarghar/urls.py
path('admin/', admin.site.urls)       ✗
path('', include('shop.urls'))        → ভেতরে
# shop/urls.py
path('products/', views.product_page) ✓
shop/views.pyএখনো SQL যায়নি
def product_page(request):
    products = (Product.objects
        .select_related('category')
        .filter(stock__gt=0).order_by('price'))
    return render(request, 'shop/products.html',
                  {'products': products})
ORMসত্যিকারের SQL, কলাম ছাঁটা
SELECT "shop_product"."id", …, "shop_category"."name"
FROM "shop_product"
INNER JOIN "shop_category"
  ON ("shop_product"."category_id" = "shop_category"."id")
WHERE "shop_product"."stock" > 0
ORDER BY "shop_product"."price" ASC
Template → responseসত্যিকারের curl, ছাঁটা
<h1>বাজারঘর</h1>
<ul>
    <li>Turmeric 200g (Spices): 95 টাকা</li>
    <li>Red Chilli 200g (Spices): 110 টাকা</li>
    …
</ul>
ফেরার পথserver log
"GET /products/ HTTP/1.1" 200
# middleware উল্টো ক্রমে response-এ header বসায়,
# যেমন X-Frame-Options
  1. Request এলো। Browser /products/ চাইল। Django-র dev server বা production-এ gunicorn এটি Python-এর একটি HttpRequest object বানায়।
  2. Middleware. settings.py-এর MIDDLEWARE তালিকা ওপর থেকে নিচে চলে, Express-এর app.use() chain-এর মতো। এখানেই session, CSRF আর request.user তৈরি হয়।
  3. URLconf. ROOT_URLCONF থেকে শুরু। admin/ মেলেনি, include('shop.urls')-এ ঢুকে products/ মিলল। প্রথম মিলেই খোঁজা থামে।
  4. View. product_page একটি QuerySet বানায় আর template-এর নাম দেয়। এই মুহূর্তে কোনো SQL যায়নি, শুধু প্রশ্নটি লেখা হয়েছে।
  5. ORM. Template যখন {% for %} দিয়ে QuerySet-এ লুপ চালায়, তখনই ১টি SQL যায়। select_related-এর কারণে এতে INNER JOIN, তাই ক্যাটাগরির নামও একসাথে আসে।
  6. Template. প্রতিটি পণ্যের জন্য একটি <li>. Stock ০ হওয়ায় Soybean Oil বাদ, আর দাম অনুযায়ী সাজানো। প্রতিটি {{ }} নিজে থেকে escape হয়।
  7. Response ফেরে। একই middleware উল্টো ক্রমে response দেখে, header যোগ করে। Status 200. পুরো পথে আমরা নিজে কোনো SQL বা HTTP header লিখিনি।

৩ · Model আর ORM — অলস QuerySet

Django-র ORM Active Record ধাঁচের: প্রতিটি model class একটি টেবিল, প্রতিটি object একটি সারি, আর object নিজেই save() করতে পারে। পর্ব ৩৮-এ (ORM-এর ধারণা) এই ধাঁচ আর Prisma-র পার্থক্য দেখেছেন। বাজারঘরের দুটি model:

from django.db import models

class Category(models.Model):
    name = models.CharField(max_length=80, unique=True)

class Product(models.Model):
    category = models.ForeignKey(Category, on_delete=models.PROTECT,
                                 related_name='products')
    name = models.CharField(max_length=120)
    price = models.PositiveIntegerField()          # টাকা, পূর্ণসংখ্যা
    stock = models.PositiveIntegerField(default=0)

লক্ষ করুন: id লিখিনি, Django নিজে দেয়। PositiveIntegerField ডেটাবেসে একটি CHECK নিয়মও বসায়, সেটি পরের অংশে migration-এর SQL-এ দেখবেন। on_delete=models.PROTECT মানে পণ্য থাকা অবস্থায় ক্যাটাগরি মোছা যাবে না। এই ছোট lab-এ সরলতার জন্য দাম পূর্ণ টাকায় রাখা হয়েছে; আসল দোকানে পর্ব ৩২-এর মতো পয়সায় পূর্ণসংখ্যা রাখাই নিরাপদ।

QuerySet লিখলেই SQL যায় না

Product.objects.filter(...) যা ফেরত দেয় তার নাম QuerySet. এটি ফল নয়, একটি প্রশ্নের খসড়া। চায়ের দোকানে অর্ডারের স্লিপ লেখার মতো: যতক্ষণ স্লিপ রান্নাঘরে না যায়, চুলা জ্বলে না। আপনি স্লিপে “চিনি কম”, “দুধ বেশি” যোগ করতে পারেন, প্রতিবার নতুন স্লিপ হয়। একে বলে lazy evaluation. আমাদের project-এ চালানো একটি ছোট script:

সত্যিকারের run · Django 6.1.2 · CaptureQueriesContext দিয়ে query গোনা · SQL লাইন ছাঁটা নয়

qs  = Product.objects.filter(stock__gt=0).order_by('price')
qs2 = qs.filter(category__name='Tea')

after filter(): 0 queries
SQL: SELECT "shop_product"."id", "shop_product"."category_id", "shop_product"."name", "shop_product"."price", "shop_product"."stock" FROM "shop_product" INNER JOIN "shop_category" ON ("shop_product"."category_id" = "shop_category"."id") WHERE ("shop_product"."stock" > 0 AND "shop_category"."name" = Tea) ORDER BY "shop_product"."price" ASC
after iterating: 1 queries ['Green Tea 100g', 'Sylhet Tea 400g']
iterate same qs again: 1 queries
count(): 5 -> 2 queries

চারটি জিনিস পড়ুন। প্রথম, দুটি filter()-এর পরেও ০টি query. দ্বিতীয়, category__name লিখতেই ORM নিজে JOIN বসিয়েছে; দুই underscore (__) মানে সম্পর্ক পেরিয়ে যাওয়া। তৃতীয়, একই qs2-তে দ্বিতীয়বার লুপ চালালে নতুন query যায়নি, কারণ QuerySet নিজের ভেতরে ফল জমা (cache) রাখে। চতুর্থ, আগে না চালানো qs-এর count() একটি আলাদা COUNT query পাঠিয়েছে।

str(qs.query)-এ = Tea উদ্ধৃতিচিহ্ন ছাড়া দেখাচ্ছে। এটি পড়ার সুবিধার জন্য বানানো লেখা, ডেটাবেসে যাওয়া আসল SQL নয়। আসলে মানটি আলাদা parameter হিসেবে যায়, তাই SQL injection হয় না। এই লেখা কপি করে সরাসরি চালাবেন না। আর Product.objects.raw() বা extra()-তে f-string দিয়ে মান জুড়বেন না; params ব্যবহার করুন।

এবার মডেলটি নিজে চালিয়ে দেখুন। নিচের শেখানোর মডেলটি আসল Django নয়। এটি শুধু Python-এর sqlite3 দিয়ে একই ধারণা দেখায়: filter() নতুন খসড়া ফেরত দেয়, আর লুপ শুরু হলে তবেই SQL যায়।

# শেখানোর মডেল: Django-র lazy QuerySet-এর ধারণা, আসল Django নয়
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE product (name TEXT, price INT, stock INT)")
db.executemany("INSERT INTO product VALUES (?, ?, ?)", [
    ("Miniket Rice 5kg", 420, 30), ("Soybean Oil 1L", 190, 0),
    ("Sylhet Tea 400g", 260, 18), ("Turmeric 200g", 95, 25)])
sent = []
db.set_trace_callback(sent.append)   # ডেটাবেসে যাওয়া প্রতিটি SQL জমা

class QuerySet:
    def __init__(self, where=(), order=None):
        self.where, self.order, self.cache = list(where), order, None
    def filter(self, cond, value):     # নতুন QuerySet, SQL চলে না
        return QuerySet(self.where + [(cond, value)], self.order)
    def order_by(self, col):
        return QuerySet(self.where, col)
    def sql(self):
        s = "SELECT name, price FROM product"
        if self.where:
            s += " WHERE " + " AND ".join(c for c, _ in self.where)
        return s + (f" ORDER BY {self.order}" if self.order else "")
    def __iter__(self):                # এখানেই প্রথম SQL চলে
        if self.cache is None:
            params = [v for _, v in self.where]
            self.cache = db.execute(self.sql(), params).fetchall()
        return iter(self.cache)

qs = QuerySet().filter("stock > ?", 0).filter("price < ?", 300).order_by("price")
print("built:", qs.sql())
print("SQL sent so far:", len(sent))
print("rows:", list(qs))
print("SQL sent so far:", len(sent))
list(qs)
print("after 2nd loop:", len(sent))

আউটপুটে প্রথমে built: SELECT name, price FROM product WHERE stock > ? AND price < ? ORDER BY price, তারপর SQL sent so far: 0. খসড়া তৈরি, কিন্তু ডেটাবেস কিছুই জানে না। list(qs) চলতেই rows: [('Turmeric 200g', 95), ('Sylhet Tea 400g', 260)] আর গণনা ১. দ্বিতীয় list(qs)-এর পরেও after 2nd loop: 1, কারণ ফল self.cache-এ জমা ছিল। মানগুলো ? দিয়ে আলাদা যায়, আসল Django-ও তাই করে। আসল QuerySet-এ len(), list(), bool(), slicing-এ step আর repr()-ও query চালায়।

৪ · N+1: select_related আর prefetch_related

Lazy আচরণের উল্টো পিঠ আছে। Prisma সম্পর্ক না চাইলে আনে না, তাই পর্ব ৪২-এ (N+1 সমস্যা) লুপটি আমাদের হাতে লিখতে হয়েছিল। Django-তে p.category.name লিখলেই ORM চুপচাপ SQL পাঠায়। কোড দেখে বোঝার উপায় নেই। ফলে N+1 এখানে আরও সহজে লুকায়। বাজারঘরের seed command এটি মেপেছে:

reset_queries()
for p in Product.objects.all():
    p.category.name
self.stdout.write(f'Without select_related: {len(connection.queries)} queries')

reset_queries()
for p in Product.objects.select_related('category'):
    p.category.name
self.stdout.write(f'With select_related: {len(connection.queries)} queries')

সত্যিকারের run · python manage.py seed · ৩টি ক্যাটাগরি, ৬টি পণ্য · DEBUG=True, তাই connection.queries ভরে

$ python manage.py seed
Seeded 3 categories, 6 products
Without select_related: 7 queries
With select_related: 1 queries
  SELECT "shop_product"."id", "shop_product"."category_id", "shop_product"."name", "shop_product"."price", "shop_product"."stock", "shop_category"."id", "shop_category"."name" FROM "shop_product" INNER JOIN "shop_category" ON ("shop_product"."category_id" = "shop_category"."id")
1for p in Product.objects.all():2    p.category.name   # লুকানো SQL34for p in Product.objects.select_related('category'):5    p.category.name   # SQL নেই
এই মুহূর্তের মান
পণ্য
ক্যাটাগরি
মোট SQL
  1. তালিকা। Product.objects.all()-এ লুপ শুরু হতেই ১টি SQL, ৬টি পণ্য এলো। এখনো ক্যাটাগরি আসেনি, শুধু category_id.
  2. প্রথম পণ্য। p.category পড়তেই ORM গোপনে ক্যাটাগরি 1 (Grocery) আনল। মোট ২টি।
  3. দ্বিতীয় পণ্য। Soybean Oil-ও Grocery-র, তবু আবার একই SQL. প্রতিটি পণ্য-object আলাদা, তাদের মধ্যে ভাগ করা cache নেই। মোট ৩টি।
  4. তৃতীয় পণ্য। Tea, ক্যাটাগরি 2. মোট ৪টি।
  5. চতুর্থ পণ্য। আবার Tea. মোট ৫টি।
  6. পঞ্চম পণ্য। Spices, ক্যাটাগরি 3. মোট ৬টি।
  7. ষষ্ঠ পণ্য। আবার Spices. মোট ৭টি, অর্থাৎ ১ + ৬. আমাদের seed-এর আসল সংখ্যাও ঠিক ৭।
  8. select_related. একই কাজ এবার ১টি SQL-এ। ORM একটি INNER JOIN লিখে পণ্য আর ক্যাটাগরি একসাথে আনে। লুপের ভেতরে আর কোনো SQL নেই।

কোনটি কখন

select_related SQL JOIN লেখে, তাই এটি শুধু “একটির দিকে” যাওয়া সম্পর্কে চলে: ForeignKey আর OneToOne. “অনেকের দিকে” (একটি ক্যাটাগরির সব পণ্য, many-to-many) JOIN করলে সারি গুণ হয়ে যায়। সেখানে prefetch_related: আগে মূল তালিকা, তারপর সব id জুড়ে একটি IN query, আর জোড়া লাগানো Python-এ। আমাদের run:

সত্যিকারের run · একই project, ৩টি ক্যাটাগরি

categories loop, no prefetch: 4 queries
with prefetch_related: 2 queries
  2nd SQL: SELECT "shop_product"."id", "shop_product"."category_id", "shop_product"."name", "shop_product"."price", "shop_product"."stock" FROM "shop_product" WHERE "shop_product"."category_id" IN (1, 2, 3)

৩টি ক্যাটাগরিতে লুপে ১ + ৩ = ৪, prefetch_related-এ ২. সূত্রটি Prisma-র include-এর query strategy-র মতোই। নিচের কোডে তিনটি পথ একসাথে গুনে দেখুন। এটি আসল Django নয়, সাধারণ sqlite3; ORM ভেতরে যে SQL লেখে, সেগুলো হাতে লেখা।

# N+1 গোনা: sqlite3-এর trace callback প্রতিটি SQL ধরে
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE category (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE product (id INTEGER PRIMARY KEY, name TEXT, category_id INT);
INSERT INTO category VALUES (1,'Grocery'),(2,'Tea'),(3,'Spices');
INSERT INTO product VALUES (1,'Miniket Rice',1),(2,'Soybean Oil',1),
 (3,'Sylhet Tea',2),(4,'Green Tea',2),(5,'Turmeric',3),(6,'Red Chilli',3);
""")
log = []
db.set_trace_callback(log.append)

# ১) lazy পথ: প্রতিটি product.category-র জন্য আলাদা SQL
log.clear()
for pid, name, cid in db.execute("SELECT id, name, category_id FROM product"):
    cat = db.execute("SELECT name FROM category WHERE id = ?", (cid,)).fetchone()[0]
print("lazy loop:", len(log), "queries")

# ২) select_related-এর মতো: একটি INNER JOIN
log.clear()
rows = db.execute("""SELECT p.name, c.name FROM product p
                     INNER JOIN category c ON p.category_id = c.id""").fetchall()
print("join     :", len(log), "query,", len(rows), "rows")
print(rows[:2])

# ৩) prefetch_related-এর মতো: ২টি SQL, দ্বিতীয়টি IN দিয়ে
log.clear()
cats = db.execute("SELECT id, name FROM category").fetchall()
ids = [c[0] for c in cats]
marks = ",".join("?" * len(ids))
prods = db.execute(f"SELECT name, category_id FROM product WHERE category_id IN ({marks})", ids).fetchall()
print("prefetch :", len(log), "queries")
print(log[-1])

ফল: lazy loop: 7 queries, join : 1 query, 6 rows, আর prefetch : 2 queries. শেষ লাইনে SELECT name, category_id FROM product WHERE category_id IN (1,2,3), যেখানে trace callback ?-এর জায়গায় আসল মান বসিয়ে দেখিয়েছে। Django-র ৭ আর ১ সংখ্যা দুটি এখানেও মিলেছে। IN-এর ? চিহ্নগুলো গুনে তৈরি, মান নয়, তাই f-string এখানে নিরাপদ।

Numerical: ধরুন ডেটাবেস আলাদা মেশিনে, প্রতিটি query-র round trip (RTT) ২ ms, আর পাতায় ৫০টি পণ্য, প্রতিটিতে দুটি ForeignKey (ক্যাটাগরি, বিক্রেতা)। Lazy পথে ১ + ২ × ৫০ = ১০১টি query, শুধু অপেক্ষায় ১০১ × ২ = ২০২ ms. select_related('category', 'seller')-এ ১টি query, ২ ms. দুটিই prefetch_related দিয়ে করলে ৩টি query, ৬ ms. পাতায় পণ্য বাড়লে প্রথমটির খরচ বাড়ে, বাকি দুটির নয়।

Django 6.1-এর নতুন পথ: 6.1-এ model field-এর fetch_mode এসেছে। আমাদের run-এ (তখন একটি পণ্য যোগ হয়ে ৭টি) ডিফল্ট FETCH_ONE-এ ৮টি query, Product.objects.fetch_mode(models.FETCH_PEERS)-এ ২টি, কারণ প্রথম p.category পড়তেই বাকি সব পণ্যের ক্যাটাগরি একটি IN-এ আসে। FETCH_RAISE-এ লুকানো query না চালিয়ে error দেয়: FieldFetchBlocked: Fetching of Product.category blocked. Test-এ এটি N+1 ধরার ভালো উপায়। নতুন বলে পুরোনো tutorial আর AI-এর উত্তরে এটি পাবেন না; ব্যবহারের আগে আপনার version-এর docs মিলিয়ে নিন।

৫ · Migration — model বদলালে কী ঘটে

পর্ব ৪০-এ (Migration আর seed) Prisma Migrate দেখেছেন। Django-তেও একই চিন্তা: model হলো কাঙ্ক্ষিত অবস্থা, migration ফাইল হলো সেখানে পৌঁছানোর ধাপ। দুটি কমান্ড: makemigrations model আর আগের migration-এর তুলনা করে নতুন ফাইল লেখে, migrate সেই ফাইল ডেটাবেসে চালায়। মাঝে sqlmigrate দিয়ে আসল SQL পড়ে নেওয়া ভালো অভ্যাস।

সত্যিকারের run · প্রথম migration · Django-র নিজের app-গুলোর লাইন ছাঁটা

$ python manage.py makemigrations shop
Migrations for 'shop':
  shop\migrations\0001_initial.py
    + Create model Category
    + Create model Product

$ python manage.py sqlmigrate shop 0001
BEGIN;
CREATE TABLE "shop_category" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(80) NOT NULL UNIQUE);
CREATE TABLE "shop_product" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(120) NOT NULL, "price" integer unsigned NOT NULL CHECK ("price" >= 0), "stock" integer unsigned NOT NULL CHECK ("stock" >= 0), "category_id" bigint NOT NULL REFERENCES "shop_category" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX "shop_product_category_id_14d7eea8" ON "shop_product" ("category_id");
COMMIT;

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, shop
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  …
  Applying sessions.0001_initial... OK
  Applying shop.0001_initial... OK

দুটি জিনিস চোখে রাখুন। প্রথমত, Django foreign key-তে নিজে থেকে index (shop_product_category_id_14d7eea8) বানিয়েছে, যা পর্ব ৪২-এ PostgreSQL-এ Prisma দেয়নি। দ্বিতীয়ত, প্রথম migrate-এ admin, auth, session-এর টেবিলও তৈরি হলো। Batteries এই টেবিলগুলোতেই থাকে। এবার পণ্যে একটি নতুন field যোগ করি: “বিশেষ পণ্য” চিহ্ন।

  1. models.pyis_featuredনতুন field
  2. makemigrations0002ফাইল
  3. sqlmigrateSQLপড়া
  4. migrateOKডেটাবেস
shop/models.pyএকটি লাইন যোগ
class Product(models.Model):
    …
    stock = models.PositiveIntegerField(default=0)
    is_featured = models.BooleanField(default=False)
makemigrationsসত্যিকারের আউটপুট
$ python manage.py makemigrations shop
Migrations for 'shop':
  shop\migrations\0002_product_is_featured.py
    + Add field is_featured to product
0002_product_is_featured.pyDjango-র লেখা ফাইল
class Migration(migrations.Migration):
    dependencies = [('shop', '0001_initial')]
    operations = [
        migrations.AddField(
            model_name='product', name='is_featured',
            field=models.BooleanField(default=False)),
    ]
sqlmigrate shop 0002সত্যিকারের SQL, কলাম ছাঁটা
CREATE TABLE "new__shop_product" (…, "is_featured" bool NOT NULL, …);
INSERT INTO "new__shop_product" (…, "is_featured")
  SELECT …, 0 FROM "shop_product";
DROP TABLE "shop_product";
ALTER TABLE "new__shop_product" RENAME TO "shop_product";
CREATE INDEX "shop_product_category_id_14d7eea8" …;
migrateসত্যিকারের আউটপুট
$ python manage.py migrate shop
Running migrations:
  Applying shop.0002_product_is_featured... OK
showmigrationsdjango_migrations টেবিল থেকে
$ python manage.py showmigrations shop
shop
 [X] 0001_initial
 [X] 0002_product_is_featured
  1. Model বদল। শুধু Python-এ একটি লাইন। ডেটাবেস এখনো কিছু জানে না, আর পুরোনো সারিগুলোর জন্য default=False দেওয়া আছে।
  2. makemigrations. Django আগের migration-গুলো থেকে model-এর পুরোনো ছবি বানায়, নতুনের সাথে তুলনা করে, আর পার্থক্যটি 0002 ফাইলে লেখে।
  3. ফাইলের ভেতরে। SQL নয়, একটি operation: AddField. dependencies বলছে এটি 0001-এর পরে চলবে। ফাইলটি কোডের সাথে version control-এ যায়।
  4. sqlmigrate. SQLite-এ কলাম যোগ করতে Django নতুন টেবিল বানিয়ে, পুরোনো সারি কপি করে (নতুন কলামে 0), পুরোনোটি মুছে নাম বদলেছে। বড় টেবিলে এটি সময় নেয়। PostgreSQL-এ সাধারণত একটি ALTER TABLE … ADD COLUMN হয়। আপনার ডেটাবেসে কী হবে, চালানোর আগে এখানেই দেখুন।
  5. migrate. SQL চলল, আর Django django_migrations টেবিলে লিখে রাখল যে shop.0002 শেষ। পরেরবার এটি আর চলবে না।
  6. যাচাই। [X] মানে প্রয়োগ হয়েছে। Production-এ deploy-এর সময় শুধু migrate চলে; makemigrations চলে developer-এর কম্পিউটারে।

Migration ফাইল হাতে মুছে নতুন করে বানানো, অথবা production-এ makemigrations চালানো সাধারণ ভুল। দুই developer একই সময়ে 0002 বানালে Django conflict জানায়, তখন makemigrations --merge লাগে। কলাম মোছা বা নাম বদলের মতো বিপজ্জনক বদলের নিরাপদ ক্রম (expand-contract) পর্ব ৪০-এ আছে, Django-তেও সেটিই খাটে।

৬ · View, URLconf আর form

URLconf: রিকশার রুট-তালিকা

URLconf হলো urlpatterns নামের একটি তালিকা। প্রতিটি path() একটি URL নকশা আর একটি view জোড়ে। <int:pk>-এর মতো অংশকে বলে path converter: এটি মান মিলিয়ে দেখে, Python int-এ বদলে view-কে দেয়। Express-এর :id সবসময় string দেয়; এখানে converter-এর কারণে /api/products/abc/ view পর্যন্ত পৌঁছায়ই না, সরাসরি 404.

from django.urls import path
from . import views

urlpatterns = [
    path('products/', views.product_page, name='products'),
    path('products/cbv/', views.ProductList.as_view(), name='products-cbv'),
    path('products/new/', views.ProductCreate.as_view(), name='product-new'),
    path('api/products/<int:pk>/', views.product_api, name='product-api'),
]

name= দিয়ে URL-এর একটি নাম থাকে। Template-এ {% url 'products' %} বা Python-এ reverse('products') লিখলে URL তৈরি হয়, তাই পথ বদলালে সব লিংক নিজে বদলায়। নিচের শেখানোর মডেলটি path()-এর মিলানোর নিয়মটি অল্প কথায় দেখায়।

# শেখানোর মডেল: Django path() converter-এর মতো ছোট URL মিলানো
import re

CONVERTERS = {"int": r"[0-9]+", "str": r"[^/]+", "slug": r"[-a-zA-Z0-9_]+"}
CAST = {"int": int, "str": str, "slug": str}

def compile_route(route):
    parts, kinds = [], {}
    for piece in re.split(r"(<[^>]+>)", route):
        if piece.startswith("<"):
            kind, name = piece[1:-1].split(":")
            kinds[name] = kind
            parts.append(f"(?P<{name}>{CONVERTERS[kind]})")
        else:
            parts.append(re.escape(piece))
    return re.compile("^" + "".join(parts) + "$"), kinds

urlpatterns = [
    ("products/", "product_page"),
    ("products/<int:pk>/", "product_detail"),
    ("category/<slug:slug>/", "category_page"),
]
compiled = [(compile_route(r), view) for r, view in urlpatterns]

def resolve(path):
    for (rx, kinds), view in compiled:       # ওপর থেকে নিচে, প্রথম মিলেই থামে
        m = rx.match(path)
        if m:
            kwargs = {k: CAST[kinds[k]](v) for k, v in m.groupdict().items()}
            return view, kwargs
    return "404", {}

for p in ["products/", "products/7/", "products/abc/", "category/tea/", "products/7"]:
    print(f"{p:16} ->", resolve(p))

products/7/ পেয়েছে ('product_detail', {'pk': 7}), লক্ষ করুন 7 উদ্ধৃতি ছাড়া, অর্থাৎ সংখ্যা। products/abc/ কোনো নকশায় মেলেনি, তাই ('404', {}). শেষ লাইনে products/7-এ শেষের / নেই, তাই সেটিও 404. আসল Django-তে APPEND_SLASH চালু থাকলে CommonMiddleware এমন URL-কে /-সহ ঠিকানায় redirect করে। মডেলটি সেই ধাপ রাখেনি।

Function view আর class-based view

View দুই রকম। Function view একটি সাধারণ function: request নেয়, HttpResponse ফেরত দেয়। Class-based view (CBV) হলো তৈরি class, যার ভেতরে সাধারণ কাজের নকশা আগে থেকে লেখা: তালিকা দেখানো (ListView), একটি দেখানো (DetailView), আর form দিয়ে নতুন সারি তৈরির CreateView ইত্যাদি। আপনি শুধু কয়েকটি attribute বদলান।

def product_api(request, pk):
    p = get_object_or_404(Product.objects.select_related('category'), pk=pk)
    return JsonResponse({'id': p.pk, 'name': p.name,
                         'price': p.price, 'category': p.category.name})

class ProductList(ListView):
    queryset = (Product.objects.select_related('category')
                .filter(stock__gt=0).order_by('price'))
    template_name = 'shop/products.html'
    context_object_name = 'products'

class ProductCreate(CreateView):
    model = Product
    fields = ['category', 'name', 'price', 'stock']   # allowlist
    template_name = 'shop/product_form.html'
    success_url = '/products/'

সত্যিকারের run · runserver 127.0.0.1 · curl

$ curl -s /api/products/1/
{"id": 1, "name": "Miniket Rice 5kg", "price": 420, "category": "Grocery"}
$ curl -s -o /dev/null -w "%{http_code}" /api/products/999/
404
$ curl -s -o /dev/null -w "%{http_code}" /api/products/abc/
404

Function view পড়া সহজ, সব কিছু চোখের সামনে। CBV কম লাইনে বেশি কাজ করে, কিন্তু আসল আচরণ লুকিয়ে থাকে কয়েক স্তর inheritance-এর ভেতরে। Community-তে এ নিয়ে মতভেদ পুরোনো। অনেক দল সাধারণ CRUD-এ CBV আর বিশেষ নিয়মে function view মিশিয়ে ব্যবহার করে। নতুন হলে function view দিয়ে শুরু করাই সহজ।

Form আর validation

পর্ব ২৭-এ (Request যাচাই) Zod দিয়ে schema লিখে ইনপুট যাচাই করেছেন। Django-তে ModelForm model থেকেই নিয়ম পড়ে নেয়: ফাঁকা নাম চলবে না, দাম ঋণাত্মক হবে না, ক্যাটাগরি অবশ্যই থাকতে হবে। fields তালিকাটি একটি allowlist: তালিকার বাইরের ঘর পাঠালে চুপচাপ বাদ পড়ে, তাই কেউ is_admin=1 জুড়ে দিলেও লাভ নেই (mass assignment থেকে সুরক্ষা)।

সত্যিকারের run · ProductForm(data={'category': '999', 'name': '', 'price': '-5', 'stock': 'abc', 'is_admin': '1'})

is_valid: False
  category -> ['Select a valid choice. That choice is not one of the available choices.']
  name -> ['This field is required.']
  price -> ['Ensure this value is greater than or equal to 0.']
  stock -> ['Enter a whole number.']

চারটি ঘরেই আলাদা বার্তা, আর ক্যাটাগরি 999 ডেটাবেসে নেই, সেটিও form নিজে যাচাই করেছে। is_admin নিয়ে কোনো কথাই নেই, কারণ form ঘরটি চেনেই না। Browser থেকে ProductCreate-এ ভুল ডেটা পাঠালে একই বার্তা পাতায় ফেরে, status 200; সঠিক ডেটায় 302 দিয়ে তালিকায় পাঠায়:

সত্যিকারের run · /products/new/-এ CSRF token-সহ POST · HTML ছাঁটা

--- name ফাঁকা, price -5
errorlist" id="id_name_error"><li>This field is required.
errorlist" id="id_price_error"><li>Ensure this value is greater than or equal to 0.
200
--- সঠিক ডেটা: Darjeeling Tea 100g, 240
302 http://127.0.0.1:8167/products/

JSON API-তে Django-র ভুলের নিয়ম আলাদা: ভুলের পরে HTML form-এর মতো 200 দেওয়া ঠিক নয়। পর্ব ৩৩-এর (Status code আর error format) মতো 400 বা 422 আর গঠিত error দিতে হয়। এই কাজে পরের অংশের API library-গুলো সাহায্য করে।

৭ · Template আর autoescape — string literal-এর ফাঁদ

Django-র template ভাষায় দুই রকম চিহ্ন: {{ product.name }} একটি মান বসায়, {% for %} বা {% if %} নিয়ম চালায়। এটি ইচ্ছা করে সীমিত: template-এ যেকোনো Python চালানো যায় না, যাতে ডিজাইনার নিরাপদে HTML লিখতে পারেন।

<h1>বাজারঘর</h1>
<ul>
{% for product in products %}
    <li>{{ product.name }} ({{ product.category.name }}): {{ product.price }} টাকা</li>
{% empty %}
    <li>কোনো পণ্য নেই</li>
{% endfor %}
</ul>
<p>{{ note }}</p>

সবচেয়ে গুরুত্বপূর্ণ ডিফল্টটি হলো autoescape: প্রতিটি variable-এর <, >, &, উদ্ধৃতিচিহ্ন HTML-এ নিরাপদ রূপে (escaping) বসে। React-এর JSX-ও {name}-কে একইভাবে escape করে। দুই জায়গায় নিরাপত্তা ভাঙে: আপনি নিজে |safe বা mark_safe() লিখলে, আর একটি কম পরিচিত জায়গায়: template-এ সরাসরি লেখা string literal. Django-র docs অনুযায়ী literal-গুলো escape ছাড়াই বসে, যেন আগে থেকেই safe. আমাদের lab-এ একই <script> তিনভাবে চালানো হলো:

সত্যিকারের run · Django 6.1.2-এর Template(...).render()

variable : <p>&lt;script&gt;alert(1)&lt;/script&gt;</p>
literal  : <p><script>alert(1)</script></p>
|safe    : <p><script>alert(1)</script></p>

আর পুরো server-এর ভেতর দিয়েও একই ফল এসেছিল। প্রথম lab run-এ template-এ {{ x|default:"<script>alert(1)</script>" }} লেখা ছিল, curl-এর শেষ লাইন:

সত্যিকারের run · curl -s /products/ · শুধু শেষ লাইন, দুই সংস্করণের template-এ

# template-এ literal: {{ x|default:"<script>…" }}
<p><script>alert(1)</script></p>
# একই লেখা view থেকে variable হিসেবে: {{ note }}
<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>

ভাবতে পারেন, template-এ নিজে লেখা লেখা তো developer-এর হাতে, বিপদ কোথায়? বিপদ আসে যখন template-এর লেখা অন্য কোথাও থেকে আসে: অনুবাদ ফাইল, ডেটাবেসে রাখা template, অথবা AI-এর কপি করে দেওয়া কোড। আর |safe সাধারণত বসে “HTML ঠিকমতো দেখাচ্ছে না” সমস্যা তাড়াতাড়ি সারাতে। নিচে শেখানোর মডেলে তিনটি পথ নিজে চালিয়ে দেখুন। এটি Django-র আসল engine নয়, Python-এর html.escape দিয়ে নিয়মটি নকল করা।

# শেখানোর মডেল: Django template-এর autoescape, html.escape দিয়ে
from html import escape
import re

def render(template, context):
    def var(m):
        name, _, filt = m.group(1).strip().partition("|")
        value = context.get(name.strip())
        if filt.startswith('default:"') and not value:
            # template-এ লেখা literal আগে থেকেই "safe" ধরা হয়, escape হয় না
            return filt[len('default:"'):-1]
        if filt == "safe":                    # আপনি নিজে দায় নিলেন
            return str(value)
        return escape(str(value))             # ডিফল্ট: সব variable escape
    return re.sub(r"\{\{(.*?)\}\}", var, template)

note = "<script>alert(1)</script>"
print(render("<p>{{ note }}</p>", {"note": note}))
print(render('<p>{{ x|default:"<script>alert(1)</script>" }}</p>', {}))
print(render("<p>{{ note|safe }}</p>", {"note": note}))
print(render("<p>{{ name }}</p>", {"name": "Tom & Jerry's \"Tea\""}))

প্রথম লাইন <p>&lt;script&gt;alert(1)&lt;/script&gt;</p>: variable escape হয়েছে, browser এটিকে লেখা হিসেবে দেখাবে। দ্বিতীয় আর তৃতীয় লাইন কাঁচা <script>, ঠিক Django-র আসল run-এর মতো। শেষ লাইন <p>Tom &amp; Jerry&#x27;s &quot;Tea&quot;</p>: পাঁচটি বিশেষ অক্ষরই বদলেছে। Django-র escape-ও এই একই রূপ দেয়।

নিয়ম তিনটি। Template-এ HTML-যুক্ত literal লিখবেন না; লেখা view থেকে variable হিসেবে পাঠান। |safe শুধু সেই HTML-এ, যা আপনি নিজে তৈরি করেছেন বা sanitize করেছেন। আর Content Security Policy দিয়ে দ্বিতীয় স্তরের পাহারা রাখুন; Django 6.0 থেকে CSP-র জন্য নিজস্ব সুবিধা এসেছে। পুরো তালিকা পর্ব ৫০-এ (অ্যাপের নিরাপত্তা তালিকা), আর তত্ত্ব Cybersecurity কোর্সের XSS ও CSP পর্বে.

৮ · Admin, login, session আর CSRF — বাক্সেই তৈরি

Django-র সবচেয়ে বিখ্যাত ব্যাটারি admin. Model নিবন্ধন করলেই তালিকা, খোঁজা, filter, তৈরি-বদল-মোছার পাতা তৈরি হয়ে যায়। বাজারঘরের মালিক এখান থেকেই পণ্য আর দাম সামলাতে পারেন।

from django.contrib import admin
from .models import Category, Product

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ['name', 'category', 'price', 'stock']
    list_filter = ['category']
    search_fields = ['name']

admin.site.register(Category)

Admin-এ ঢুকতে একজন staff ব্যবহারকারী লাগে। python manage.py createsuperuser একটি superuser বানায়, আর পাসওয়ার্ড ডিফল্টে PBKDF2 দিয়ে hash হয়ে জমা হয় (password hash, পর্ব ৪৫-এর মতো)। আমাদের run-এ পাসওয়ার্ড ছিল একটি নকল মান, example-pass-731. এবার curl দিয়ে একটি পুরো login দেখি।

  1. /admin/302login নেই
  2. login form200csrftoken
  3. POST403 / 200যাচাই
  4. session302sessionid
GET /admin/সত্যিকারের curl
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}" /admin/
302 http://127.0.0.1:8167/admin/login/?next=/admin/
GET /admin/login/সত্যিকারের, token কাটা
Set-Cookie: csrftoken=…  (৩২ অক্ষর)
<input type="hidden" name="csrfmiddlewaretoken"
       value="VM6KAZk69CEEqdNsnKEazEB1nRR3…">  (৬৪ অক্ষর)
<input type="hidden" name="next" value="/admin/">
POST, token ছাড়াসত্যিকারের
403
<p>CSRF verification failed. Request aborted.</p>
Reason given for failure:
    CSRF token missing.
# server log: Forbidden (CSRF token missing.): /admin/login/
POST, token-সহ, ভুল পাসওয়ার্ডসত্যিকারের
200
<p class="errornote">
    Please enter the correct username and password
    for a staff account. Note that both fields may
    be case-sensitive.
POST, সঠিক পাসওয়ার্ডসত্যিকারের, cookie মান কাটা
302 http://127.0.0.1:8167/admin/
#HttpOnly_127.0.0.1 … sessionid  …  (৩২ অক্ষর)
127.0.0.1           … csrftoken  …  (login-এ Django বদলায়)
GET, sessionid-সহসত্যিকারের
$ curl -b cookies.txt /admin/                     → 200
$ curl -b cookies.txt "/admin/shop/product/?category__id__exact=2"
200   Green Tea 100g, Sylhet Tea 400g
  1. পাহারা। Login ছাড়া /admin/ চাইলে 302, login পাতায়। ?next=/admin/ মনে রাখে কোথায় ফিরতে হবে। আমাদের test-ও ঠিক এটিই যাচাই করে।
  2. Form আর token. Login পাতা একটি csrftoken cookie দেয়, আর form-এর ভেতরে একটি লুকানো csrfmiddlewaretoken. Form-এর token ৬৪ অক্ষর, কারণ প্রতি পাতায় secret-টি নতুন mask দিয়ে মোড়ানো থাকে।
  3. Token ছাড়া POST. CsrfViewMiddleware পাসওয়ার্ড দেখার আগেই 403 দিল: “CSRF token missing.” অন্য সাইট থেকে লুকিয়ে পাঠানো form ঠিক এভাবেই থামে।
  4. ভুল পাসওয়ার্ড। Token ঠিক, তাই এবার form চলল। বার্তায় বলা নেই username ভুল নাকি পাসওয়ার্ড, যাতে কেউ ব্যবহারকারীর নাম আন্দাজ করতে না পারে।
  5. Login সফল। 302, /admin/-এ। নতুন sessionid cookie, যা HttpOnly, অর্থাৎ JavaScript পড়তে পারে না। Login-এর সময় Django session id আর CSRF token দুটিই বদলে দেয়, session fixation ঠেকাতে।
  6. ভেতরে। একই cookie নিয়ে admin খুলল, আর list_filter-এর Tea ক্যাটাগরিতে দুটি পণ্য দেখাল। পুরো পথে login, session store, CSRF আর পাসওয়ার্ড hash আমরা একটি লাইনও লিখিনি।

সত্যিকারের run · python manage.py test

Found 3 test(s).
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.015s

OK

তিনটি test: stock ০ হলে পণ্য লুকানো, login ছাড়া admin-এ 302, আর না-থাকা পণ্যে 404. Django-র test client প্রতিটি test-এর জন্য আলাদা test database বানায় আর শেষে মুছে দেয়।

Session ডিফল্টে ডেটাবেসের django_session টেবিলে থাকে, cookie-তে শুধু id. পর্ব ৪৫-এ (Session-ভিত্তিক login) Express-এ যা নিজের হাতে জুড়েছেন, Django-তে তা SessionMiddleware আর AuthenticationMiddleware. CSRF-এর তত্ত্ব আছে Cybersecurity কোর্সের CSRF পর্বে. আর 403 পেলে @csrf_exempt বসিয়ে “সারানো” সবচেয়ে বিপজ্জনক অভ্যাস; আসল কারণ সাধারণত form-এ {% csrf_token %} না থাকা।

৯ · API, async, settings, deployment আর কখন Django

API: DRF আর Django Ninja

আমাদের JsonResponse ছোট কাজে যথেষ্ট। কিন্তু pagination, গঠিত validation error, token auth আর API docs লাগলে দুটি পরিচিত library আছে। লেখার সময়ে PyPI-তে দেখা সংস্করণ:

Django REST Framework

DRF দুটির মধ্যে পুরোনো, আর বহুল ব্যবহৃত বলে পরিচিত। Serializer, ViewSet, permission আর browsable API দেয়। Class-ভিত্তিক, অনেক নিয়ম আগে থেকে ঠিক করা।

djangorestframework 3.18.3 · Django 5.2–6.1

Django Ninja

Django Ninja Python-এর type hint আর Pydantic থেকে validation ও OpenAPI docs বানায়, অনেকটা FastAPI-র ধাঁচে। Async view-ও সমর্থন করে।

django-ninja 1.7.3 · Django 5.2–6.1

দুটির কোনোটি এই lab-এ চালানো হয়নি, তাই এখানে কোড দেখাচ্ছি না। কোনটি বেছে নেবেন, তা দলের অভ্যাসের ওপর নির্ভর করে: DRF পুরোনো বলে এর উদাহরণ আর নথি বেশি জমেছে, Ninja-র কোড ছোট আর type-নির্ভর। Pydantic-এর ধাঁচ বিস্তারিত দেখবেন পর্ব ৬৯-এ, FastAPI-তে।

Async view আর তার সীমা

Django-তে async def দিয়ে async view লেখা যায়, আর QuerySet-এর প্রায় প্রতিটি SQL চালানো method-এর a-যুক্ত রূপ আছে যেমন aget(), acount() আর async for লুপ। তবে Django-র docs কয়েকটি সীমা স্পষ্ট বলে, লেখার সময়ের 6.1 docs অনুযায়ী:

  • WSGI-তে async view চলে, কিন্তু লাভ কম: প্রতিটি request-এর জন্য আলাদা ছোট event loop চলে। অনেক লম্বা সংযোগ (streaming, long polling) সামলাতে ASGI-তে deploy করতে হয়।
  • Transaction এখনো async mode-এ চলে না। Transaction-এর কোড একটি সাধারণ function-এ রেখে sync_to_async() দিয়ে ডাকতে হয়।
  • Async function-এর ভেতরে সাধারণ ORM call দিলে Django SynchronousOnlyOperation error দেয়।
  • Middleware sync হলে প্রতিটি request-এ sync আর async-এর মাঝে বদলের খরচ পড়ে।

ভেতরে ORM-এর async method কতটা “আসল” async, অর্থাৎ thread ছাড়া চলে কিনা, তা version আর database driver ভেদে বদলাচ্ছে। তাই সাধারণ CRUD সাইটে async-এ যাওয়ার আগে মেপে দেখুন। বাইরের অনেক API-কে একসাথে ডাকা বা WebSocket-এর মতো কাজে async-এর লাভ সবচেয়ে স্পষ্ট। Python-এর async নিজে বুঝতে Python কোর্সের asyncio পর্ব দেখুন।

Settings, secret আর DEBUG=False

startproject যে settings.py বানায়, তা development-এর জন্য: DEBUG = True, আর SECRET_KEY ফাইলের ভেতরেই লেখা, django-insecure- দিয়ে শুরু। এই key session আর CSRF token সই করতে লাগে, তাই এটি ফাঁস হওয়া মানে বড় বিপদ। নতুন project-এ check --deploy চালালে:

সত্যিকারের run · python manage.py check --deploy · লম্বা বার্তা ছাঁটা

SystemCheckError: System check identified some issues:

ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.console.EmailBackend).

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. …
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. …
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' …
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. …
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. …
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.

System check identified 8 issues (0 silenced).

লক্ষ করুন MAILERS: Django 6.1-এ email backend সাজানোর এই নতুন setting এসেছে, যা release notes অনুযায়ী ভবিষ্যতে পুরোনো EMAIL_BACKEND-এর জায়গা নেবে। পুরোনো tutorial-এ শুধু EMAIL_BACKEND পাবেন। Production-এর জন্য আমরা আলাদা একটি settings ফাইল বানালাম, যা secret পড়ে environment variable থেকে:

import os
from .settings import *  # noqa

DEBUG = False
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']      # না থাকলে চালুই হবে না
ALLOWED_HOSTS = (os.environ['DJANGO_ALLOWED_HOSTS'].split(',')
                 if os.environ.get('DJANGO_ALLOWED_HOSTS') else [])

সত্যিকারের run · --settings=bazarghar.settings_prod · secret-এর মান একটি নকল লম্বা লেখা

# DJANGO_SECRET_KEY ছাড়া
KeyError: 'DJANGO_SECRET_KEY'
# secret আছে, ALLOWED_HOSTS নেই
CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.
# দুটিই আছে: না-থাকা পণ্য
<title>Not Found</title> … The requested resource was not found on this server.
404
# অচেনা Host header
$ curl -H "Host: evil.example" /products/
400

DEBUG=True-এ একই 404 পাতায় পুরো URL তালিকা দেখাত (Page not found at /api/products/999/), আর error হলে settings ও stack trace. DEBUG=False-এ শুধু ছোট বার্তা। ALLOWED_HOSTS অচেনা Host header-কে 400 দিয়ে ফিরিয়ে দেয়, যা host header দিয়ে বিষাক্ত লিংক বানানোর চেষ্টা থামায়। Secret সামলানোর পুরো পদ্ধতি দেখবেন পর্ব ৭৮-এ, Environment আর secret বিষয়ের পর্বে।

Deployment: WSGI, ASGI আর server

runserver শুধু development-এর জন্য; Django-র docs নিজেই production-এ এটি ব্যবহার করতে নিষেধ করে। Production-এ project-এর wsgi.py বা asgi.py-কে একটি আলাদা server চালায়। WSGI হলো Python web app আর server-এর মধ্যে সাধারণ sync চুক্তি; Gunicorn (লেখার সময়ে 26.2.0) এর পরিচিত server. ASGI হলো async চুক্তি; Uvicorn (লেখার সময়ে 0.54.0) এর একটি server. সামনে সাধারণত Nginx-এর মতো একটি reverse proxy থাকে, যা HTTPS আর static ফাইল সামলায়।

# উদাহরণ, এই lab-এ চালানো হয়নি (Windows-এ gunicorn চলে না)
$ python manage.py collectstatic --noinput
$ python manage.py migrate
$ gunicorn bazarghar.wsgi:application --workers 3 --bind 127.0.0.1:8000
# অথবা ASGI
$ uvicorn bazarghar.asgi:application --workers 3

Docker, CI/CD আর hosting বেছে নেওয়া মডিউল ১২-এ (পর্ব ৮০–৮২) আসবে। আর পর্ব ৮৫-তে Django, Laravel আর Next.js-কে পাশাপাশি রেখে stack বাছাইয়ের ছক বানাবেন।

কখন Django বেছে নেবেন

পরিস্থিতিDjango কেমন মানায়কারণ
কনটেন্ট-ভরা সাইট: খবর, ব্লগ, শিক্ষা, ক্যাটালগখুব ভালোORM, admin, template, sitemap, i18n সব আছে
Admin panel বা back office: অর্ডার, স্টক, অনুমোদনখুব ভালোAdmin প্রায় বিনা খরচে, permission আর audit-এর ভিত্তি তৈরি
ডেটা-ভারী অভ্যন্তরীণ সিস্টেম, রিপোর্টভালোPython-এর ডেটা লাইব্রেরি পাশেই, ORM-এ aggregation
দল Python জানে, বা ML কোডের পাশে web লাগবেভালোএক ভাষা, এক টুলচেইন
খুব interactive UI (চ্যাট, এডিটর)শুধু backend হিসেবেসামনে React বা Next.js, পেছনে DRF বা Ninja
অনেক লম্বা সংযোগ, শুধু ছোট JSON APIবিকল্প ভাবুনFastAPI বা Node-এর async মডেল বেশি সরাসরি
পুরো দল TypeScript-এ, frontend-backend এক typeভেবে দেখুনদুই ভাষায় type ভাগ করতে OpenAPI-র মতো সেতু লাগে

চাকরির বাজার নিয়ে একটি সতর্ক কথা: Django-র চাহিদা দেশ, শহর আর বছরভেদে বদলায়, তাই কোনো সংখ্যা আমরা দিচ্ছি না। নিজের এলাকার চাকরির বিজ্ঞাপন দেখে বিচার করুন। নীতিগত দিকটি স্থির: একটি framework ভালো করে শিখলে, তার ধারণা (routing, ORM, migration, middleware) অন্যটিতে সহজে নেওয়া যায়।

আরও গভীরে: WSGI-র নিয়ম লেখা আছে PEP 3333-এ (মূল PEP 333, ২০০৩): server একটি callable ডাকে, যা environ আর start_response নেয়। ASGI এর async উত্তরসূরি, যেখানে app scope, receive আর send পায়, তাই একটি সংযোগে অনেক বার্তা চলতে পারে। QuerySet-এর lazy আচরণের পেছনে আছে Python-এর __iter__ আর __getitem__; Django-র QuerySet._fetch_all() একবার চলে ফল _result_cache-এ রাখে। আর select_related-এর JOIN আসলে ORM-এর Query object-এর একটি গাছ থেকে SQL compiler-এর তৈরি।

পরীক্ষা ও ইন্টারভিউতে:

  • MVT আর MVC-র সম্পর্ক কী? একই ভাগ; Django-র view = controller-এর কাজ, template = view, আর URL পাঠানোর কাজ framework নিজে করে।
  • select_related আর prefetch_related-এর পার্থক্য? প্রথমটি JOIN, ForeignKey/OneToOne-এ; দ্বিতীয়টি আলাদা IN query, many-to-many ও উল্টো দিকে।
  • QuerySet কখন SQL চালায়? লুপ, list(), len(), bool(), slicing-এ step বা repr()-এর সময়; filter()-এ নয়।
  • makemigrations আর migrate-এর পার্থক্য? প্রথমটি ফাইল লেখে, দ্বিতীয়টি ডেটাবেসে চালায়।
  • Production-এ Django-র তিনটি জরুরি setting? DEBUG=False, গোপন SECRET_KEY, আর ALLOWED_HOSTS.

১০ · AI-এর যুগে: Django কোড যাচাই

Django প্রায় কুড়ি বছরের পুরোনো, তাই ইন্টারনেটে পুরোনো কোড অনেক। AI প্রায়ই সেই পুরোনো ধাঁচ মিশিয়ে দেয়। কোড চলে, কিন্তু আপনার version-এ ভুল বা অনিরাপদ।

AI-এর ভুলকীভাবে ধরবেন
পুরোনো url(r'^…$') বা MIDDLEWARE_CLASSES লেখেpython -m django --version দেখে সেই version-এর docs খুলুন; url() Django 4.0-এ সরানো হয়েছে
403 দেখে @csrf_exempt বসিয়ে দেয়Form-এ {% csrf_token %} আছে কি না দেখুন; exempt শুধু সই-যাচাই করা webhook-এ
HTML দেখানোর জন্য |safe বা mark_safe()লেখা কোথা থেকে আসে খুঁজুন; ব্যবহারকারীর লেখা হলে কখনো নয়
লুপে p.category.name, কোনো select_related নেইassertNumQueries test, অথবা 6.1-এ FETCH_RAISE
raw() বা cursor.execute()-এ f-stringparams দিয়ে মান পাঠান
SECRET_KEY কোডে, DEBUG=True, ALLOWED_HOSTS=['*']check --deploy চালান, secret environment-এ রাখুন
Async view-এ সাধারণ ORM call, বা অস্তিত্বহীন settingচালিয়ে দেখুন; SynchronousOnlyOperation বা system check বলে দেবে

AI-কে আপনার version, ফাইল আর মাপ একসাথে দিন, তাহলে অনুমান কম করবে:

আমি Django 6.1 আর Python 3.14 ব্যবহার করি। নিচে আমার models.py, views.py, urls.py আর একটি template দিলাম। এই পাতায় query সংখ্যা এখন [সংখ্যা]। [কোড পেস্ট করো। SECRET_KEY, পাসওয়ার্ড বা আসল ক্রেতার ডেটা দিও না।] ১. N+1 কোথায়, লাইন ধরে বলো; select_related না prefetch_related, কারণসহ। ২. template-এ |safe, mark_safe বা HTML-যুক্ত literal আছে কি না দেখাও। ৩. কোনো view-তে csrf_exempt বা login ছাড়া লেখার সুযোগ আছে কি না বলো। ৪. শুধু Django 6.1-এ আছে এমন API ব্যবহার করো; নিশ্চিত না হলে স্পষ্ট বলো, আমি docs দেখে নেব। ৫. নতুন package যোগ করবে না। শেষে একটি assertNumQueries test লিখে দাও।

AI-এর Django কোড হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১“Batteries included” কি সবসময় সুবিধা? Express বা Flask-এর মতো ছোট framework-এর সাথে এর আসল লেনদেন কী?

Django-র জন্ম সংবাদপত্রের ওয়েব দলে, যেখানে প্রতিদিন নতুন ধরনের পাতা আর কঠিন deadline. সেখানে প্রতিটি project-এ login, admin, form আর ডেটাবেস নতুন করে জোড়া সময়ের অপচয়। তাই সব একসাথে, একই নকশায় বানানো হলো। লাভ তিনটি। প্রথমত, শুরু দ্রুত: আমাদের lab-এ admin, login, session আর CSRF একটি লাইন না লিখেই চলেছে। দ্বিতীয়ত, অংশগুলো একে অন্যকে চেনে: admin model পড়ে, form model পড়ে, auth session ব্যবহার করে। তৃতীয়ত, নিরাপত্তার ডিফল্ট একজায়গায় পর্যালোচিত হয়, আর Django-র নিরাপত্তা দল সেগুলো একসাথে হালনাগাদ করে।

দাম কী? Express-এ আপনি প্রতিটি অংশ বেছে নেন, তাই যা দরকার নেই তা থাকে না, আর কোনো অংশ বদলাতে ফ্রেমওয়ার্কের সাথে লড়তে হয় না। Django-তে auth-এর User model, template engine বা ORM বদলানো সম্ভব, কিন্তু অন্য অংশগুলো সেগুলোর ওপর নির্ভর করে, তাই বদল ব্যয়বহুল। একটি পরিচিত উদাহরণ: project শুরুর পরে নিজস্ব User model-এ যাওয়া কঠিন, তাই Django-র docs নতুন project-এ শুরু থেকেই custom User model রাখার পরামর্শ দেয়। আবার শুধু ছোট JSON API-র জন্য পুরো Django বেশি ভারী মনে হতে পারে।

Flask (২০১০) আর Express (২০১০-এর দিকে) উল্টো দর্শন নিয়েছে: ছোট মূল অংশ, বাকি extension. দুই দর্শনেই বড় সাইট চলছে। আসল প্রশ্ন দলের: সিদ্ধান্তগুলো কে নেবে? Django-তে অনেক সিদ্ধান্ত আগে থেকে নেওয়া, তাই নতুন সদস্য দ্রুত project বোঝেন। ছোট framework-এ প্রতিটি project আলাদা নকশার হয়, যা স্বাধীনতা দেয়, আবার নথি আর শৃঙ্খলাও বেশি চায়।

মূল উপলব্ধি: Batteries included মানে সিদ্ধান্তগুলো আগে থেকে নেওয়া। আপনার কাজ যদি সেই সিদ্ধান্তের সাথে মেলে, এটি বিরাট গতি দেয়; না মিললে প্রতিটি ব্যতিক্রমে দাম দিতে হয়।

প্র ০২Django controller-কে “view” আর view-কে “template” বলে কেন? এই নামের বিতর্ক কি শুধু শব্দের খেলা?

MVC-র জন্ম ১৯৭০-এর দশকের শেষে Smalltalk-এর desktop UI-তে। সেখানে view পর্দা আঁকত, controller keyboard আর mouse-এর ঘটনা ধরত, আর model বদলালে view-কে খবর দিত। ওয়েবে এই ছবি হুবহু খাটে না, কারণ browser আর server আলাদা, আর প্রতিটি request আলাদা জীবন পায়। ফলে web framework-গুলো নামটি নিল, কিন্তু অর্থ বদলে গেল। Rails যাকে controller বলে, Django-র FAQ তাকেই বলে view: “কোন ডেটা দেখানো হবে” সিদ্ধান্তকারী অংশ। আর “দেখতে কেমন” অংশের নাম template.

FAQ খোলাখুলি স্বীকার করে যে, প্রচলিত নামগুলো নিয়েই বিতর্ক আছে, আর বলে controller-এর কাজটি সম্ভবত framework নিজেই করে: URLconf দেখে সঠিক view-কে ডাকা। এ কারণেই নামটি MTV বা MVT. এটি শুধু শব্দের খেলা নয়, কারণ ভুল নাম ভুল প্রত্যাশা তৈরি করে। Rails থেকে আসা কেউ Django-র views.py-তে HTML খুঁজবেন, অথচ সেখানে আছে Python-এর যুক্তি। React থেকে আসা কেউ “view” শুনে component ভাববেন।

ব্যবহারিক শিক্ষা হলো: নাম নয়, দায়িত্ব দেখুন। কোন অংশ HTTP request পড়ে, কোন অংশ ব্যবসার নিয়ম জানে, কোন অংশ HTML বানায়, আর কোন অংশ ডেটাবেসে যায়। পর্ব ২৯-এ (Backend-এর গঠন) controller, service, repository-র যে ভাগ দেখেছেন, তা Django-তেও খাটে। বড় Django project-এ অনেকে view পাতলা রাখেন আর নিয়ম রাখেন model-এর method-এ বা আলাদা services.py-তে। Django এটি বাধ্য করে না, community-তে এ নিয়েও মত ভিন্ন।

মূল উপলব্ধি: Pattern-এর নাম framework ভেদে বদলায়, দায়িত্বের ভাগ বদলায় না। নতুন framework শিখতে গেলে আগে খুঁজুন request কোথায় ঢোকে, নিয়ম কোথায় থাকে আর HTML কোথায় তৈরি হয়।

প্র ০৩Lazy QuerySet সুবিধা না ফাঁদ? Prisma-র “না চাইলে আনে না” নকশার সাথে তুলনা করুন।

Lazy আচরণের দুটি আলাদা অর্থ আছে, আর দুটিকে আলাদা রাখা জরুরি। প্রথমটি QuerySet-এর lazy হওয়া: filter(), order_by() জুড়তে থাকলেও SQL যায় না, ফল লাগলেই যায়। এটি নিখাদ সুবিধা। একটি view অর্ধেক QuerySet বানিয়ে template-কে দিতে পারে, একটি helper আরেকটি শর্ত যোগ করতে পারে, আর শেষে একটিই SQL যায়। আমাদের run-এ দুটি filter()-এর পরে ০টি query, লুপে ১টি।

দ্বিতীয়টি সম্পর্কের lazy loading: p.category লিখলেই গোপনে SQL. এখানেই ফাঁদ। Python-এ attribute পড়া দেখতে বিনামূল্যের, অথচ এখানে একটি network যাত্রা। ৬টি পণ্যে ৭টি query সংখ্যায় ছোট, কিন্তু ৫০০ পণ্যে ৫০১, আর ডেটাবেস দূরে থাকলে প্রতিটির সাথে round trip. Hibernate, ActiveRecord, Eloquent সবাই একই অভিজ্ঞতা পেয়েছে। তাই নতুন রূপে lazy loading বন্ধ করার উপায় আসছে: Rails-এ strict_loading, Laravel-এ preventLazyLoading, আর Django 6.1-এ FETCH_RAISE. একই release-এ FETCH_PEERS উল্টো পথ নিয়েছে: lazy রেখেই প্রথমবার পড়লে সব সঙ্গীর জন্য একসাথে আনে।

Prisma প্রথম দিন থেকে দ্বিতীয় অর্থের lazy loading রাখেনি। সম্পর্ক না চাইলে আসে না, তাই লুপের query হাতে লিখতে হয় আর review-তে চোখে পড়ে। বিনিময়ে প্রতিটি query-তে আগে থেকেই ভাবতে হয় কী লাগবে। Django-তে প্রোটোটাইপ দ্রুত, পরে মাপতে হয়; Prisma-তে শুরুতে একটু বেশি লেখা, পরে চমক কম। কোনটি ভালো, তা দলের শৃঙ্খলার ওপর নির্ভর করে। Django-র assertNumQueries এই শৃঙ্খলাকে test-এ বেঁধে রাখার পুরোনো উপায়।

মূল উপলব্ধি: QuerySet-এর lazy হওয়া রচনার সুবিধা দেয়; সম্পর্কের lazy loading খরচ লুকায়। প্রথমটি উপভোগ করুন, দ্বিতীয়টিকে মাপুন আর test-এ ধরে রাখুন।

প্র ০৪Autoescape থাকা সত্ত্বেও Django সাইটে XSS কীভাবে ঢোকে? String literal-এর নিয়মটি কেন এমন?

Django-তে autoescape আসে ১.০ প্রকাশের আগে, ২০০৭–০৮ সালের দিকে। তার আগে প্রতিটি variable-এ হাতে |escape লিখতে হতো। এই এক বদলে অসংখ্য XSS বন্ধ হয়, কারণ নিরাপদ পথটি ডিফল্ট হলো। তবু XSS ঢোকে কয়েকটি পরিচিত দরজা দিয়ে। প্রথম, |safe আর mark_safe(): rich text বা markdown দেখাতে গিয়ে developer এগুলো বসান, আর লেখাটি যে ব্যবহারকারীর কাছ থেকে এসেছে তা ভুলে যান। দ্বিতীয়, template-এর বাইরে HTML বানানো, যেমন Python-এ f-string দিয়ে HTML জুড়ে mark_safe-এ মোড়ানো। তৃতীয়, JavaScript-এর ভেতরে মান বসানো: HTML escape সেখানে যথেষ্ট নয়, তাই Django json_script filter দেয়। চতুর্থ, attribute-এ উদ্ধৃতিচিহ্ন ছাড়া মান বসানো।

String literal-এর নিয়মটি নকশাগত সিদ্ধান্ত। Template লেখেন developer, তাই ধরা হয় template-এর ভেতরে লেখা লেখা বিশ্বাসযোগ্য, ঠিক যেমন template-এর বাকি HTML. {{ x|default:"<b>নেই</b>" }} লিখলে আপনি সম্ভবত সত্যিই মোটা হরফ চান। Docs এটি স্পষ্ট লিখে রেখেছে। ঝুঁকি দেখা দেয় যখন “template লেখক” আর বিশ্বাসযোগ্য থাকেন না: ডেটাবেসে রাখা template যা admin-এর কেউ বদলাতে পারেন, অনুবাদ ফাইল যা বাইরের কেউ দেন, অথবা অন্যের কোড না পড়েই কপি করা।

React-এর সাথে তুলনা শিক্ষণীয়। JSX-এ {value} সবসময় escape হয়, আর কাঁচা HTML বসাতে হয় ইচ্ছা করে কুৎসিত নামের dangerouslySetInnerHTML দিয়ে। নামটিই সতর্কবার্তা। Django-র |safe ছোট আর নিরীহ দেখায়, তাই code review-তে চোখ এড়িয়ে যায়। সমাধান: |safe আর mark_safe-কে review-এর লাল চিহ্ন হিসেবে খোঁজা, ব্যবহারকারীর HTML একটি sanitizer দিয়ে পরিষ্কার করা, আর CSP দিয়ে দ্বিতীয় স্তর রাখা।

মূল উপলব্ধি: ভালো ডিফল্ট বেশিরভাগ ভুল থামায়, সব নয়। প্রতিটি “নিরাপদ বলে চিহ্নিত” জায়গা আসলে একটি বিশ্বাসের দাবি, আর সেই দাবি কে করছেন তা জানতে হবে।

প্র ০৫Django কি async framework হয়ে উঠছে? বাজারঘরের মতো সাইটে async view-এ যাওয়া কি যুক্তিসঙ্গত?

Django গড়ে উঠেছে WSGI-র যুগে: একটি request, একটি thread বা process, শুরু থেকে শেষ পর্যন্ত। ২০১০-এর দশকে WebSocket আর লম্বা সংযোগের চাহিদা বাড়লে Python-এ ASGI চুক্তি আসে, প্রথমে Django Channels project থেকে। এরপর ধাপে ধাপে Django নিজে async শেখে: সংস্করণ ৩.০-তে (২০১৯) ASGI-তে চালানোর সমর্থন, ৩.১-এ (২০২০) async view আর middleware, ৪.১-এ (২০২২) ORM-এর async interface. তারিখগুলো প্রধান release-এর হিসাবে, খুঁটিনাটির জন্য release notes দেখুন।

তবে এখনো সীমা আছে, আর 6.1-এর docs নিজেই সেগুলো লেখে। Transaction async mode-এ চলে না। Sync middleware থাকলে প্রতিটি request-এ sync-async বদলের খরচ। আর async ORM method ভেতরে কতটা thread-মুক্ত, তা database driver-এর ওপর নির্ভর করে। অর্থাৎ async view লিখলেই ডেটাবেসের কাজ দ্রুত হয় না। ডেটাবেস-নির্ভর একটি পণ্য তালিকার পাতায় সময়ের বড় অংশ SQL-এ যায়; সেখানে লাভ আসে সঠিক index আর select_related থেকে, async থেকে নয়।

Async কোথায় সত্যিই লাভ দেয়? যেখানে একটি request অনেক বাইরের অপেক্ষা একসাথে করে: তিনটি payment বা courier API একসাথে ডাকা, SSE দিয়ে AI-এর উত্তর stream করা, বা হাজারো খোলা সংযোগ রাখা। তখন ASGI আর Uvicorn-এর মতো server-এ এক process অনেক সংযোগ সামলায়, thread ছাড়া। বাজারঘরে তাই বাস্তব পথ হলো মিশ্র: সাধারণ পাতা sync থাক, আর নির্দিষ্ট দুয়েকটি অংশ async. Sync আর async-এর মাঝে প্রতিটি বদলের খরচ আছে, তাই docs লুপের ভেতরে বারবার বদল না করে পুরো লুপটি একবারে sync_to_async()-এ পাঠাতে বলে।

মূল উপলব্ধি: Django আজ async চালাতে পারে, কিন্তু async হলেই দ্রুত হয় না। অপেক্ষা কোথায় হচ্ছে তা মেপে দেখুন: ডেটাবেসে হলে query সারান, বাইরের network-এ হলে async-এর কথা ভাবুন।

অনুশীলন

হিসাব, পূর্বাভাস, কোড সমালোচনা আর একটি কমান্ডের কাজ।

  1. পূর্ণ numerical: একটি Django পাতায় ৪০টি অর্ডার দেখানো হয়। প্রতিটি অর্ডারের ক্রেতা (ForeignKey) আর অর্ডারের সারিগুলো (উল্টো দিকের সম্পর্ক, অনেক) লাগে, দুটিই লুপে পড়া হয়। RTT ৩ ms, প্রতিটি SQL-এ ডেটাবেসের কাজ ০.৫ ms. (ক) কোনো optimization ছাড়া মোট query আর সময়? (খ) select_related('customer') আর prefetch_related('items') দিয়ে? (গ) প্রতি সেকেন্ডে ৩০টি এমন request এলে দুই পথে ডেটাবেস সেকেন্ডে কতটি query পায়?

    (ক) ১ + ২ × ৪০ = ৮১টি query. সময় ≈ ৮১ × (৩ + ০.৫) = ৮১ × ৩.৫ = ২৮৩.৫ ms.

    (খ) প্রথম query-তে অর্ডার আর ক্রেতা JOIN-এ, দ্বিতীয় query-তে সব সারি IN দিয়ে: মোট ২টি। সময় ≈ ২ × ৩.৫ = ৭ ms.

    (গ) প্রথম পথে ৩০ × ৮১ = ২,৪৩০ query/সেকেন্ড, দ্বিতীয় পথে ৩০ × ২ = ৬০. শুধু পাতার গতি নয়, ডেটাবেসের ভারও প্রায় ৪০ গুণ কমে।

  2. Output অনুমান করুন: একটি Django shell-এ query গোনা হচ্ছে (শুরুতে ০)। ডেটাবেসে stock > ০ এমন ৫টি পণ্য। ক্রমে চলে: qs = Product.objects.filter(stock__gt=0) → qs2 = qs.order_by('price') → names = [p.name for p in qs2] → again = [p.name for p in qs2] → n = qs.count() → first = qs2[0]. প্রতিটি লাইনের পরে query-র মোট সংখ্যা কত, আর n-এর মান কী?

    প্রথম দুই লাইনের পরে ০: শুধু খসড়া। প্রথম লুপে ১. দ্বিতীয় লুপে এখনো ১, কারণ qs2-এর ফল জমা আছে। qs.count() একটি আলাদা COUNT query, কারণ qs নিজে কখনো চালানো হয়নি: মোট ২, আর n = 5. আমাদের আসল run-এ ঠিক এই আচরণ দেখা গেছে: count(): 5 -> 2 queries.

    qs2[0]: qs2-এর cache আগেই ভরা, তাই Django সেখান থেকেই দেয়, মোট থাকে ২. Cache না থাকলে এটি LIMIT 1-যুক্ত একটি নতুন query পাঠাত।

  3. নকশা সমালোচনা: একজন developer বাজারঘরের খোঁজার পাতা লিখেছেন: @csrf_exempt def search(request): q = request.GET.get('q', ''); items = Product.objects.raw(f"SELECT * FROM shop_product WHERE name LIKE '%{q}%'"); return render(request, 'search.html', {'items': items, 'q': mark_safe(q)}). Template-এ {% for p in items %}{{ p.category.name }}{% endfor %}. Settings-এ DEBUG = True আর ALLOWED_HOSTS = ['*']. সমস্যাগুলো কী, আর ভালো রূপ কেমন?
    • raw()-এ f-string: SQL injection. সমাধান Product.objects.filter(name__icontains=q).
    • mark_safe(q): ব্যবহারকারীর লেখা কাঁচা HTML হয়ে ফেরে, reflected XSS. q সাধারণ variable হিসেবে পাঠান।
    • p.category.name লুপে: N+1. select_related('category') যোগ করুন।
    • @csrf_exempt একটি GET পাতায় অর্থহীন, আর ভবিষ্যতে POST যোগ হলে বিপদ। সরিয়ে দিন।
    • কোনো সীমা নেই: হাজার পণ্যে সব আসে। Pagination দিন (পর্ব ৩৪-এর মতো)।
    • DEBUG=True error-এ settings ও কোড দেখায়; ALLOWED_HOSTS=['*'] Host header যাচাই বন্ধ করে। Production-এ নির্দিষ্ট domain আর DEBUG=False.
  4. কমান্ডের কাজ: নিজের যেকোনো Django project-এ python manage.py check --deploy চালান। নতুন project-এ সাধারণত যে সতর্কবার্তাগুলো আসে (W004, W008, W009, W012, W016, W018, W020), প্রতিটির জন্য কোন setting বদলাবেন? কোনটি HTTPS ছাড়া চালু করলে বিপদ হতে পারে?
    1. W018: DEBUG = False.
    2. W020: ALLOWED_HOSTS = ['bazarghar.example'], environment থেকে।
    3. W009: নতুন লম্বা এলোমেলো SECRET_KEY, environment বা secret store থেকে; পুরোনোটি ফাঁস হয়ে থাকলে বদলান।
    4. W012, W016: SESSION_COOKIE_SECURE = True, CSRF_COOKIE_SECURE = True.
    5. W008: SECURE_SSL_REDIRECT = True, অথবা reverse proxy-তে redirect; proxy থাকলে SECURE_PROXY_SSL_HEADER-ও ঠিক করুন।
    6. W004: SECURE_HSTS_SECONDS. এটিই সবচেয়ে সাবধানের: HSTS একবার browser-এ বসলে নির্ধারিত সময় পর্যন্ত HTTP-তে ফেরা যায় না। ছোট মান (যেমন ৩০০ সেকেন্ড) দিয়ে শুরু করে পুরো সাইট HTTPS-এ নিশ্চিত হলে বাড়ান।

    শেষে আবার check --deploy চালিয়ে দেখুন সংখ্যা ০-তে নেমেছে কি না।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমDjango-তে একটি GET request-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Server request পেয়ে HttpRequest বানায়
  2. MIDDLEWARE তালিকা ওপর থেকে নিচে চলে
  3. URLconf URL মিলিয়ে view বেছে নেয়
  4. View QuerySet আর template ঠিক করে
  5. Template লুপ চালায়, তখন SQL যায় আর HTML তৈরি হয়
  6. Response middleware হয়ে উল্টো ক্রমে browser-এ ফেরে

Django-র MVT-তে কোন অংশটি Express-এর route handler-এর সবচেয়ে কাছের?

  • Template
  • View
  • Model
  • Migration

Django-তে qs = Product.objects.filter(stock__gt=0).order_by('price') লাইনটি চললে, আগে query সংখ্যা ০ থাকলে, এখন কতটি SQL ডেটাবেসে গেছে?

  • ২টি
  • ১টি
  • ০টি
  • পণ্যের সংখ্যার সমান

একটি পাতায় ৩০টি পণ্য, প্রতিটির ক্যাটাগরি (ForeignKey) লুপে p.category.name দিয়ে পড়া হয়, কোনো optimization নেই। মোট কতটি query, আর select_related('category') দিলে কত?

  • ৩১ আর ১
  • ৩০ আর ১
  • ৩১ আর ২
  • ক্যাটাগরির সংখ্যা + ১ আর ১

Django template-এ {{ x|default:"<script>alert(1)</script>" }} লেখা, আর x context-এ নেই। Autoescape চালু থাকলে HTML-এ কী বসবে?

  • &lt;script&gt;alert(1)&lt;/script&gt;
  • ফাঁকা লেখা
  • TemplateSyntaxError
  • কাঁচা <script>alert(1)</script>

Django-তে model-এ নতুন field যোগ করার পরে production-এ deploy-এর সময় সঠিক কাজ কোনটি?

  • Production server-এ makemigrations আর migrate দুটিই চালানো
  • Migration ফাইল কোডের সাথে পাঠানো, production-এ শুধু migrate
  • কিছুই না, Django চালু হওয়ার সময় নিজে টেবিল বদলায়
  • হাতে ALTER TABLE চালানো

Django-তে DEBUG = False আর ALLOWED_HOSTS = [] দিয়ে runserver চালালে কী হয়?

  • Server চালু হয়, সব host মেনে নেয়
  • চালু হয়, কিন্তু প্রতিটি পাতায় 404
  • CommandError দিয়ে চালু হতে অস্বীকার করে
  • Django নিজে DEBUG আবার True করে দেয়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Batteries included: ORM, migration, admin, auth, session, CSRF, form আর template একসাথে; project হলো সাইট, app হলো তার একেকটি কাজের অংশ।
  2. MVT = web MVC: view সিদ্ধান্ত নেয় কোন ডেটা, template ঠিক করে দেখতে কেমন, আর URLconf-সহ framework নিজে controller-এর কাজ করে।
  3. QuerySet lazy, সম্পর্ক গোপনে SQL চালায়: ৬টি পণ্যে ৭টি query থেকে select_related-এ ১টি; অনেকের দিকে prefetch_related.
  4. Migration: makemigrations developer-এর কাছে, sqlmigrate দিয়ে পড়া, production-এ শুধু migrate.
  5. নিরাপত্তার ডিফল্ট ভাঙবেন না: autoescape (literal আর |safe-এর ব্যতিক্রম মনে রেখে), {% csrf_token %}, DEBUG=False, secret environment-এ, আর check --deploy.

পরের পর্বে: Python-এর আরেক পথ: শুধু API, শুরু থেকেই async, আর type hint থেকে validation ও docs. পর্ব ৬৯-এ FastAPI.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান