添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

我把 LoveLive! 兩季看完了!μ’s 在第一季的成長充滿感動啊。 \真姫最高/

……呃好啦,之前講了 用 Flask 去架一個抽籤網站 。不過我們最終的目標是用 Django 嘛,所以接下來就要改寫。也藉這個機會比較一下兩個 Framework 設計概念的不同( 例如 Django 一開始寫有多冗 Flask 寫到最後有多冗 )。

From Flask to Django

為了轉換但又不要一下子把所有 Django 的功能都放進來,中間過程有很多「不常見的寫法」。想要直接寫 Django best practice 的話,可以參考 TP 大大的 《為程式人寫的 Django Tutorial 》 ,他的規劃是 30 個單元做一個訂餐系統。

過程中會用到很多 Django API,沒有解釋的話可以到 官網 去查使用。另外我發現如果能用 debugger 去 trace Django 執行的流程能幫助理解,想要一個精美的 debugger 的話可以裝像 PyCharm 的 IDE。

整體的規劃會漸近把 Django 的功能加進來,依序應該是:

  • Django View, Template
  • Django Model, ORM
  • Django Form
  • (Django Admin 沒有用到)
  • 如果看 Django doc 首頁的話,也是分這幾個部份,雖然這篇文章並不會把所有概念都介紹一遍。

    另外,在改寫的時候會跳過用 raw SQL,因為完全不用 ORM 有點難銜接其他 Django 部份。有興趣的話在講完 Model 之後可以參考 Details。

  • From Flask to Django
  • Django 初始設定
    • Django server
    • 第一個 Django app
    • Django settings
    • Database Migration
    • URL dispatcher
    • Django Model and ORM
      • Migration the tracker of model changes
      • ORM queries in shell
      • Data in ORM and fixtures
      • Django Template
      • More on Django’s model, template and view (MTV)
      • Django Form
        • More Django form in view
        • Details
          • Raw SQL
          • Better QuerySet
          • Timezone
          • POST form and CSRF
          • Django 初始設定

            一樣開一個 Python 虛擬環境(這時候就是它的好處了,能把不同專案的套件隔離)。

            pip install django pytz ipython pyyaml
            

            pytz前一篇已經介紹過,是處理時區的套件。IPython 全名是 Interactive Python,同樣是 Python shell 但提供了很多附加功能,最常用的應該是自動補完。PyYAML 用來處理 YAML 物件,可裝可不裝,不裝之後的例子就用 JSON 即可。

            我們的專案根目錄是 demo_django_draw_member。因為 Django 的設定很多,先在這目錄下用 django-admin 把基本的架構建起來。我們建了一個名為 draw_site 的專案(Project)。

            (VENV) $ django-admin startproject draw_site
            

            執行完之後應該會多出一堆檔案,結構如下。注意到有兩層 draw_site

            demo_django_draw_member/
            └── draw_site/
                ├── draw_site/
                │   ├── __init__.py
                │   ├── settings.py
                │   ├── urls.py
                │   └── wsgi.py
                └── manage.py*
            

            之後工作的目錄其實是 demo_django_draw_member/draw_site/,也就是有 manage.py 的那層目錄,之後的路徑都是相對於 demo_django_draw_member/draw_site/。介紹一下每個檔案。

          • manage.py 之後就會取代 django-admin 的功能。兩者最大的差別是 manage.py 知道 project 的設定。
          • draw_site/settings.py 裡面存著 Django 的各種設定,像 secret key、database、template engine、app 等。
          • draw_site/urls.py 裡面存著 URL dispatching 設定,即哪個路徑要用哪個 function 去處理。
          • draw_site/wsgi.py WSGI 是規範 Python web server 的標準,通常不會動這個檔案就不細提。Flask、Django 都是相容 WSGI 的實作。
          • 一個 Django 由一個 project 和很多個 apps 所組成。每個 app 就專注在網站的某個功能上,各自包著各自需要的 database schema、template、view logics。這樣的好處是同樣的功能就不用重寫,同時在很大的網站時這樣的結構有助於管理運作的邏輯。

            Django server

            先把 Django 跑起來看看吧。

            $ python manage.py runserver
            Django version 1.8.5, using settings 'draw_site.settings'
            Starting development server at http://127.0.0.1:8000/
            

            這是 Django 內建在什麼 URL 都沒設定時的歡迎畫面。看到這個至少表示基本的 settings 正常。Django 跟 Flask 一樣,內建的 server 會在 source code 有改變的時候 reload,所以一直開著跑也可以。

            第一個 Django app

            我們的網站只會用到一個 app,把它建出來取名為 draw_member

            python manage.py startapp draw_member
            
            demo_django_draw_member/
            └── draw_site/
                ├── draw_member/
                │   ├── __init__.py
                │   ├── admin.py
                │   ├── migrations/
                │   ├── models.py
                │   ├── tests.py
                │   └── views.py
                ├── draw_site/
                │   └── ...
                └── manage.py*
            

            可以看到 app 與 project 的架構是不一樣的。

            要把這個新的 app 加到 project 裡,修改 draw_site/settings.py

            # draw_site/settings.py
            INSTALLED_APPS = (
                'draw_member',    # 加這一行
                'django.contrib.admin',
                'django.contrib.auth',
                'django.contrib.contenttypes',
                'django.contrib.sessions',
                'django.contrib.messages',
                'django.contrib.staticfiles',
            

            預設其實裝了很多 app。暫時不理他們是什麼。

            Django settings

            先簡單介紹一下 draw_site/settings.py。除了剛剛用到 INSTALLED_APPS,講幾個跟這邊比較有關的參數。

            # Database
            # https://docs.djangoproject.com/en/1.8/ref/settings/#databases
            DATABASES = {
                'default': {
                    'ENGINE': 'django.db.backends.sqlite3',
                    'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
            # Internationalization
            # https://docs.djangoproject.com/en/1.8/topics/i18n/
            LANGUAGE_CODE = 'en-us'
            TIME_ZONE = 'UTC'
            USE_TZ = True
            

            DATABSES 裡定義了使用的資料庫。預設會使用 db.sqlite3 這個 SQLite 資料庫。

            再來是語言、時區的設定。預設是 UTC 並且使用 timezone,也就是 server 的時間都是用 UTC 記錄的。

            Database Migration

            在什麼 code 都還沒寫之前,介紹一個 database 觀念:migration

            在之前的例子可以知道,我們會先設計一個資料庫該存什麼東西,整個網站流程會怎麼用這些資料,這些形成 table schema。但是隨著時間,可能網站有新的功能,很難說完全不去更動 schema。

            更動 schema 不是件簡單的事,如果是上 production 的網站,資料庫會有運作以來累積的資料,總不能 schema 改了這些資料就丟掉吧?而且在網站開發的時候,在不同版本的(或不同人開發的)code 就可能有不同的 schema。要怎麼確保 code 與 database 的狀態就要靠 migration。

            ……一開始就這麼複雜?好啦我們的例子沒有用到 migration 大多數的功能,只有用它 initiate database。內建的 app 都有自己的 database schema,可以用它把資料庫的 table 建出來。

            $ python manage.py migrate
            Operations to perform:
              Synchronize unmigrated apps: messages, staticfiles
              Apply all migrations: sessions, auth, contenttypes, admin
            Synchronizing apps without migrations:
              Creating tables...
                Running deferred SQL...
              Installing custom SQL...
            Running migrations:
              Rendering model states... DONE
              Applying contenttypes.0001_initial... OK
              Applying auth.0001_initial... OK
              Applying admin.0001_initial... OK
              Applying contenttypes.0002_remove_content_type_name... OK
              Applying auth.0002_alter_permission_name_max_length... OK
              Applying auth.0003_alter_user_email_max_length... OK
              Applying auth.0004_alter_user_username_opts... OK
              Applying auth.0005_alter_user_last_login_null... OK
              Applying auth.0006_require_contenttypes_0002... OK
              Applying sessions.0001_initial... OK
            

            migration 就會一步步把 database 調整到符合現在 code 的狀態,這些調整就會記錄在 <app>/migrations/ 底下,等等就會看到了。

            URL dispatcher

            我們接下來要改首頁,把 Django 預設的 / 首頁換成 Hello World。

            Flask URL routing 是直接用 decorator 寫在 view function 上面。幫大家回顧一下:

            @app.route('/')
            def index():
                return "<p>Hello World!</p>"
            

            Django 的 view 和 URL 是分開的,首先是 view:

            # draw_member/views.py
            from django.shortcuts import render  # 先暫時留著
            from django.http import HttpResponse
            def home(request):
                return HttpResponse("<p>Hello World!</p>")
            

            結構上大同小異(也因為有 WSGI 規範的關係啦)。

            再來是 URL 設定。我們先把 URL 加在 project 設定。這邊可能覺得設定有點分散比較怪,等一下再把它放到 app 裡面。

            # draw_site/urls.py
            """draw_site URL Configuration
            The `urlpatterns` list routes URLs to views. For more information please see:
                https://docs.djangoproject.com/en/1.8/topics/http/urls/
            from django.conf.urls import include, url
            from django.contrib import admin
            from draw_member.views import home
            urlpatterns = [
                url(r'^$', home, name="home"),
                url(r'^admin/', include(admin.site.urls)),
            

            概念也很簡單,把要的 view function 從 app import 進來(所以 app 目錄是個 Python module,底下會 __init__.py),給一個 regex 表示的路徑,後面放上處理 function 以及一個 optional 的名字,這個名字就代表了這個 URL 路徑,之後可以反查。

            測一下確認設定都是正確的。

            $ curl -XGET "localhost:8000"
            <p>Hello World!</p>
            

            再看一下 draw_site/urls.py,可以看到 Django 預設放了個 /admin 後面用的是 include(app.urls),表示這一整包只要是 admin/ 開頭的 URL 都交給 admin.site.urls 去處理路徑。這樣方便 app 在不同網站中重覆利用,因為可能放的路徑都不一樣,但一個 app 內的 URL 處理會有一致性。

            馬上來改寫一下。首先在 app draw_member 底下加一個 urls.py

            # draw_member/urls.py
            from django.conf.urls import include, url
            from .views import home  # explicit relative import
            urlpatterns = [
                url(r'^$', home, name="home"),
            

            基本上格式就是照抄原本就有的。因為放在同個 app 裡面了,import view 時就可以用 explicit relative import(這不是 relative import 喔)

            原本的 urls.py 就改成把 URL 的處理「dispatch」給這個 app,改成底下這樣。

            # draw_site/urls.py
            from django.conf.urls import include, url
            from django.contrib import admin
            urlpatterns = [
                url(r'^admin/', include(admin.site.urls)),
                url(r'^', include('draw_member.urls')),
            

            r'^' 代表從根目錄就交給這個 app 去管理,也因為這樣比較專一的路徑要放前面,像是 /admin。用字串表示在執行的時候才 import 這個 module,不想也可以拿掉字串把 app import 進來。

            以上就是最基本的 URL dispatching

            Django Model and ORM

            接著處理資料庫的問題。當然可以在 Django 裡面寫 raw SQL,但這邊提供另一個想法:Object-relational Mapping (ORM)。ORM 把資料用物件導向的方式整理,把 SQL、table、database 的細節交給 ORM engine 去翻譯。這可以在找到非常多介紹,直接跳到實作。

            ┌─────────────────────┐ │ members │ ├─────────────────────┤ │ id INTEGER │

            回想一下我們的 schema 設計。改用 ORM 來思考我們就會有成員(Member)以及抽籤歷史(History)兩大 models。Member 記錄了名字與所屬團體;History 會記錄時間、這筆抽籤是屬於哪個成員的。

            在 Django 中,model 定義在 models.py 裡面,馬上來寫寫看。

            # draw_members/models.py
            from django.db import models
            from django.utils.timezone import now
            class Member(models.Model):
                name = models.CharField(max_length=256)
                group_name = models.CharField(max_length=256)
                def __str__(self):
                    return '%s of %s' % (self.name, self.group_name)
            class History(models.Model):
                member = models.ForeignKey(Member, related_name="draw_histories")
                # now() will return datetime.utcnow()
                time = models.DateTimeField(default=now)
                def __str__(self):
                    return '%s at %s' % (self.member.name, self.time)
            

            一個 class 裡的屬性就對應到一個欄位(Field),欄位會有他的型別以及資料庫實作上的限制(例如字串有上限,當然也可以不設)。Field type 可以參考官網

            Member 底下都是字串所以是 CharFieldHistory 稍微複雜一點,時間的記錄 date 用 DateTimeField,這樣欄位拿回來就會轉換成 Python datetime object;另一個 member 用的是 ForeignKey,也就是 relationship field,來表示這筆抽籤屬於拿個成員。後面的 related_name 提供了反查功能,也就是能從一個 member 去查他所有的 histories。

            同時先寫好兩個 class 底下的 __str__,這樣等下在 Python shell 操作時容易辨認每個物件的內容。

            Migration the tracker of model changes

            多說無用,馬上來試一試。

            ……等等,想到 migration 了嗎?每次更動 database model 都要跑 migration,確保 code 與資料庫狀態一致。

            $ python manage.py makemigrations draw_member
            python manage.py makemigrations draw_member
            Migrations for 'draw_member':
              0001_initial.py:
                - Create model History
                - Create model Member
                - Add field member to history
            

            可以看到 Django 很聰明的知道我們多定義了兩個 models,裡面有些對應到資料庫的欄位型態。這些資訊會寫在 migration file 裡面,

            # draw_member/migrations/0001_initial.py
            class Migration(migrations.Migration):
                dependencies = [
                operations = [
                    migrations.CreateModel(
                        name='History',
                        fields=[
                            ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)),
                            ('time', models.DateTimeField(default=django.utils.timezone.now)),
                    migrations.CreateModel(
                        name='Member',
                        fields=[
                            ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)),
                            ('name', models.CharField(max_length=256)),
                            ('group_name', models.CharField(max_length=256)),
                    migrations.AddField(
                        model_name='history',
                        name='member',
                        field=models.ForeignKey(to='draw_member.Member', related_name='draw_histories'),
            

            注意到 Django ORM 自動幫我們加了 id 這個 primary key,等等就會用到。Migration 裡面的細節等對 Django 更熟了之後就能慢慢了解了。

            有了新的 migration 就要同步資料庫的狀態,

            $ python manage.py migrate
            Running migrations:
              Rendering model states... DONE
              Applying draw_member.0001_initial... OK
            

            ORM queries in shell

            接下來我們操作一下 ORM。

            $ python manage.py shell
            

            就會打開一個 Python shell。如果裝了 IPython 就會打開 IPython shell。 這個與一般的有什麼差別呢?他會帶有 Django project 的設定。如果是從一般的 shell 可以先跑以下的指令來達到相同的效果。

            $ DJANGO_SETTINGS_MODULE="draw_site.settings" python
            >>> import django
            >>> django.setup()
            
            In [1]: from draw_member.models import Member, History
            In [2]: m1 = Member(name="高坂 穂乃果", group_name="μ's")
            In [4]: m2 = Member(name="平沢 唯", group_name="K-ON!")
            In [5]: m1, m2
            Out[5]: (<Member: 高坂 穂乃果 of μ's>, <Member: 平沢 唯 of K-ON!>)
            In [7]: m1.save()
            In [8]: m2.save()
            In [6]: h1 = History(member=m1)
            In [9]: h1.save()
            

            使用上就把資料當作物件來操作,如同 ORM 字面的意思。注意只有在 .save() 才真正被存到資料裡。拿沒有存的 object 來操作 database 就會出現 exception。

            >>> h_failed = History(member=Member(name='FF', group_name='f'))
            >>> h_failed.save()
            Traceback (most recent call last):
            IntegrityError: NOT NULL constraint failed: draw_member_history.member_id
            

            覺得麻煩的話,用 Model.objects.create() 就可以一步搞定。正確的存好之後,現在資料庫已經有資料了。我們可以先在 SQLite 裡確認。

            -- sqlite3 db.sqlite3
            sqlite> .header on
            sqlite> SELECT * FROM draw_member_member;
            id|name|group_name
            1|高坂 穂乃果|μ's
            2|平沢 唯|K-ON!
            sqlite> SELECT * FROM draw_member_history;
            id|time|member_id
            1|2015-10-05 15:17:32.061384|1
            

            透過像剛剛 object 的操作,我們也能建出如同手寫 SQL 一樣的資料庫,當然像 idmember_id 這些欄位是 ORM engine 自動幫我們做出來的,這些可以自訂,不過預設的行為不難理解。

            要怎麼從 ORM 像剛剛下 SQL 一樣撈資料呢?

            >>> from draw_member.models import Member, History
            >>> Member.objects.all()
            [<Member: 高坂 穂乃果 of μ's>, <Member: 平沢 唯 of K-ON!>]
            >>> History.objects.all()
            [<History: 高坂 穂乃果 at 2015-10-05 15:17:32.061384+00:00>]
            

            資料透過 Model.objects 這個 Manager 去查詢,細節就去看 Django 關於 Making queries 的內容吧。查詢資料庫就會回傳 QuerySet,這並不會真的去「查」資料庫,但先把指令存著等真的要用到值時才去計算,也就是 lazy evaluation。

            QuerySet 底下就有很多對應到 SQL 指令的查詢,像是拿回所有 objects 的 QuerySet.all(),前面已經用過了。或者篩選的 QuerySet.filter()

            >>> Member.objects.filter(group_name='K-ON!')
            [<Member: 平沢 唯 of K-ON!>]
            >>> Member.objects.filter(group_name__contains='!')
            [<Member: 平沢 唯 of K-ON!>]
            

            其中 <field>__contains 就是 Django ORM 為了實做像 SQL LIKE 指令的對應欄位。

            先講幾個有關的,首先每個 Model 都有個 primary key pk,預設指到 Model.id 這個欄位上,另用 QuerySet.get() 可以拿到單一物件,這時候萬用的 pk 就派上用場了。

            >>> Member.objects.get(pk=1)
            <Member: 高坂 穂乃果 of μ's>
            

            查 relation 也很簡單,

            >>> h1 = History.objects.get(pk=1)
            >>> h1.member
            <Member: 高坂 穂乃果 of μ's>
            >>> h1.member.name
            '高坂 穂乃果'
            

            還記得之前設得 related_name="draw_histories",表示我們能從 Member 反查回去該人相關的歷史,

            >>> m1 = Member.objects.get(pk=1)
            >>> m1.draw_histories.all()
            [<History: 高坂 穂乃果 at 2015-10-05 15:17:32.061384+00:00>]
            

            最後我們來刪資料,

            >>> Member.objects.all().delete()
            >>> History.objects.all().delete()
            

            當然一開始我們可以暴力把 db.sqlite3 整個刪掉再重新 python manage.py migrate 一次就可以讓 database 對應的 table 都建立好,不過只適用於 SQLite 而已。或者,正確的「清空資料庫」做法是用 flush 指令,

            $ python manage.py flush
            You have requested a flush of the database.
            This will IRREVERSIBLY DESTROY all data currently in the 'draw_site/db.sqlite3' database,
            and return each table to an empty state.
            Are you sure you want to do this?
                Type 'yes' to continue, or 'no' to cancel: yes
            Installed 0 object(s) from 0 fixture(s)
            Installed 0 object(s) from 0 fixture(s)
            

            Data in ORM and fixtures

            我們把 members.csv 的資料填到資料庫吧。這邊就不用細說了。

            In [1]: import csv
            In [2]: with open('../../draw_member/members.csv', newline='') as f:
               ...:    csv_reader = csv.DictReader(f)
               ...:    members = [
               ...:    (row['名字'], row['團體'])
               ...:    for row in csv_reader
               ...:    ]
            In [3]: from draw_member.models import Member
            In [4]: for m in members:
               ...:     Member(name=m[0], group_name=m[1]).save()
            

            可以自己檢查一下是不是 14 個人都寫到資料庫了。

            不過現在有個問題是,之後可能會常常把資料庫砍掉重練,或者要把這些(或很多來源)的資料讀到資料庫,每次都重新讀寫也是可以,但有沒有別的做法能把資料先存起來?

            這邊就要介紹 Django fixtures 了。他能把資料庫的資料存成 JSON、YAML(需要 PyYAML)等格式。

            一般 fixtures 是被在 <app>/fixtures/ 目錄底下,記得先把目錄建出來。

            mkdir draw_member/fixtures
            

            根據 database 的內容建立 fixtures 可以使用 dumpdata 指令:

            python manage.py dumpdata \
                --format=yaml \
                --indent=4 \
                --output draw_member/fixtures/anime_members.yaml
                draw_member.Member \
            
            # draw_member/fixtures/anime_members.yaml
            -   fields: {group_name: "\u03BC's", name: "\u9AD8\u5742 \u7A42\u4E43\u679C"}
                model: draw_member.member
                pk: 1
            -   fields: {group_name: "\u03BC's", name: "\u7D62\u702C \u7D75\u91CC"}
                model: draw_member.member
                pk: 2
            # ...
            

            用 JSON 輸出也可以,改成 --format=json 就可以了

            "model": "draw_member.member", "pk": 1, "fields": { "name": "\u9ad8\u5742 \u7a42\u4e43\u679c", "group_name": "\u03bc's"

            我們可以用 python manage.py flush 把資料庫清掉,模擬資料的讀入。

            $ python manage.py loaddata anime_members.yaml
            Installed 14 object(s) from 1 fixture(s)
            

            這樣資料的存取就介紹得差不多了。更多的細節可以參考官網 model layer 的說明。

            Django Template

            在進行下去之前,先確認我們的目錄結構是一樣的。

            demo_django_draw_member/
            └── draw_site/
                ├── db.sqlite3
                ├── draw_member/
                │   ├── __init__.py
                │   ├── admin.py
                │   ├── fixtures/
                │   │   ├── anime_members.json
                │   │   └── anime_members.yaml
                │   ├── migrations/
                │   │   ├── 0001_initial.py
                │   │   └── __init__.py
                │   ├── models.py
                │   ├── tests.py
                │   ├── urls.py
                │   └── views.py
                ├── draw_site/
                │   ├── __init__.py
                │   ├── settings.py
                │   ├── urls.py
                │   └── wsgi.py
                └── manage.py*
            

            Django 的 template 預設是放在 <app>/templates/ 底下。不過為了在跨 app 時不要衝到名字,我們會多包一層 app 為名的資料夾。

            mkdir -p draw_member/templates/draw_member
            

            它跟 Flask 用的 Jinja2 templates 乍看下非常類似(Jinja2 模仿 Django template),兩者最大的差別是在 Jinja2 裡能很自由的使用 Python function,不過 Django 靠的是 template tag 以及 filter。我們的例子兩者是沒差多少。

            一樣先把 base.html 以及 home.html 做出來。我們也先把 Form 寫上了,暫時先用 GET。

            {# draw_member/templates/draw_member/base.html #}
            <!DOCTYPE html>
            <html lang="en">
              <meta charset="UTF-8">
              <meta name="viewport" content="width=device-width">
              <title>{% block title %}抽籤系統{% endblock title %}</title>
            </head>
            {% block content %}{% endblock content %}
            <h3>功能列</h3>
              <li><a href="{% url 'home' %}">首頁(抽籤)</a></li>
              <li><a href="{% url 'history' %}">歷史記錄</a></li>
            </body>
            </html>
            
            {# draw_member/templates/draw_member/home.html #}
            {% extends 'draw_member/base.html' %}
            {% block content %}
              <h1>來抽出快樂的夥伴吧!</h1>
              <p>選擇要被抽的團體</p>
              <form action="{% url 'draw' %}" method="get">
                <label for="group_name">團隊名稱:</label>
                <input type="radio" name="group_name" value="μ's">μ's
                <input type="radio" name="group_name" value="K-ON!">K-ON!
                <input type="radio" name="group_name" value="ALL" checked>(全)
                <input type="submit" value="Submit">
              </form>
            {% endblock content %}
            

            整體的概念應該很好理解。{% url 'xxxx' %} 就是 URL resolver,還記得在 urls.py 的設定時有給個 name 參數嗎,這邊就會根據那個名字回傳正確的網址。

            順便更新一下 URL 把這些 view 先加好,不然等下 runserver 會說找不到這些網址。

            # draw_members/urls.py
            from django.conf.urls import include, url
            from .views import home, draw, history
            urlpatterns = [
                url(r'^$', home, name="home"),
                url(r'^draw/$', draw, name="draw"),
                url(r'^history/$', history, name="history")
            
            # draw_members/views.py
            from django.shortcuts import render
            from django.http import HttpResponse
            def home(request):
                return HttpResponse("<p>Hello World!</p>")
            def draw(request):
                return HttpResponse("<p>Draw</p>")
            def history(request):
                return HttpResponse("<p>History</p>")
            

            緊接著改寫我們的首頁,讓它用上 home.html

            def home(request):
                return render(request, 'draw_member/home.html')
            

            Template 更多的說明可以參考官網 template layer 的說明。

            More on Django’s model, template and view (MTV)

            我們把最重要的抽籤功能實作出來吧。

            這邊需要理解的就是,Django 會把傳到 GET / POST 的參數以 dict 存在 request.GET / request.POST 裡面,@require_GET 限制只能使用 GET 去溝通。

            其他的邏輯都是照抄以前的。

            import random
            from django.shortcuts import render
            from django.http import HttpResponse, Http404
            from django.views.decorators.http import require_GET
            from .models import Member, History
            @require_GET
            def draw(request):
                # Retrieve all related members
                group_name = request.GET.get('group_name', 'ALL')
                if group_name == 'ALL':
                    valid_members = Member.objects.all()
                else:
                    valid_members = Member.objects.filter(group_name=group_name)
                # Raise 404 if no members are found given the group name
                if not valid_members.exists():
                    raise Http404("No member in group '%s'" % group_name)
                # Lucky draw
                lucky_member = random.choice(valid_members)
                # Update history
                draw_history = History(member=lucky_member)
                draw_history.save()
                return HttpResponse(
                    "<p>{0.name}(團體:{0.group_name})</p>"
                    .format(lucky_member)
            

            用 ORM 寫起來比 raw SQL 乾淨多了,不過一開始要把對應的 function 都記起來就是。 馬上測試一下,一樣偷懶先不去寫 template。

            $ curl -XGET "localhost:8000/draw/?group=ALL"
            <p>小泉 花陽(團體:μ's)</p>
            

            如果是從首頁去點的,觀察一下網址的變化。例如:http://localhost:8000/draw/?group_name=K-ON!,可以看到 form 的選項直接寫在網址列。這是使用 POST 與 GET 最大的不同。

            再來把歷史記錄的部份也寫一下,也把 template 都補上。

            {# draw_member/templates/history.html #}
            {% extends 'draw_member/base.html' %}
            {% block title %}抽籤歷史{% endblock title %}
            {% block content %}
              <h1>抽籤歷史(最近 10 筆)</h1>
              <table>
                <thead>
                  <th>名字</th>
                  <th>團體</th>
                  <th>抽中時間</th>
                </thead>
                <tbody>
                {% for history in recent_histories %}
                    <td>{{ history.member.name }}</td>
                    <td>{{ history.member.group_name }}</td>
                    <td>{{ history.time|date:"r"}}</td>
                {% endfor %}
                </tbody>
              </table>
            {% endblock content %}
            

            history.html 與本來 Flask 不一樣的地方,在用上了 date:"r" 的 filter,傳的參數接在 : 之後。也更新對應 view 的動作,

            def history(request):
                recent_draws = History.objects.order_by('-time').all()[:10]
                return render(request, 'draw_member/history.html', {
                    'recent_histories': recent_draws,
            

            可以看到預設用的是 UTC 時區,時區的轉換細節放到文末吧。我們可以在 view 裡更改要呈現的時區,

            from django.utils.timezone import activate
            def history(request):
                activate('Asia/Taipei')
                # ...
            

            這樣基本功能就搞定啦!細節一樣參考官網 view layer 的說明。

            Django Form

            直接把 form 寫在 template 裡面也是可以,有時候 form 可能跟 model 息息相關,而且 form input 多了之後每個欄位都要自己讀寫也太不直覺。想要驗証使用者的 input 的話就更複雜了。

            於是就有了 Django Form。馬上來看用起來是怎麼樣。

            # draw_member/forms.py
            from django import forms
            class DrawForm(forms.Form):
                GROUP_CHOICES = [
                    ("μ's", "μ's"),
                    ("K-ON!", "K-ON!"),
                    ("ALL", "(全)"),
                group = forms.ChoiceField(
                    choices=GROUP_CHOICES,
                    label='團隊名稱',
                    label_suffix=':',
                    widget=forms.RadioSelect,
                    initial='ALL'
            

            建了一個新的 form class,像 Model 一樣,裡面規定了每個欄位的屬性。我們這邊只有一個 group 是個單選的 ChoiceField,choices 是個 list of two-item tuples,第一個是內部的值,第二個是顯示的字。其他的都是細節的調整。

            把這個 form 用到 view 裡面。新建一個 form object form,然後把這個變數 form 傳進 template 裡面。

            from .forms import DrawForm
            def home(request):
                form = DrawForm()
                return render(request, 'draw_member/home.html', {
                    'form': form,
            

            再來修改 template,就不用自己寫 form 的內容了,改成 {{ form }} Django 就會自動產生。

            {# draw_member/home.html #}
            {% extends 'draw_member/base.html' %}
            {% block content %}
              <h1>來抽出快樂的夥伴吧!</h1>
              <p>選擇要被抽的團體</p>
              <form action="{% url 'draw' %}" method="get">
                {{ form }}
                <input type="submit" value="Submit">
              </form>
            {% endblock content %}
            

            不過這個長得跟我們原本的 form 不一樣嘛。好在 Django form 是很彈性的,form 在被 render 成 HTML 時可以提供細節的調整,大家可以參考官網 Form rendering options 調整。我直接給調好的結果吧。

              <form action="{% url 'draw' %}" method="get">
                {{ form.group.label_tag }}
                {% for radio in form.group %}
                  {{ radio.tag }}{{ radio.choice_label }}
                {% endfor %}
                <input type="submit" value="Submit">
              </form>
            

            用結果去對照每個 {{ ... }} 部件對應的 HTML 元素吧。

            More Django form in view

            Form 的功能可不只這樣,可以在創建 DrawForm 時直接把 request.GET 傳入。

            # draw_member/views.py
            def draw(request):
                # Retrieve all related members
                form = DrawForm(request.GET)
                if form.is_valid():
                    group_name = form.cleaned_data['group']
                    if group_name == 'ALL':
                        valid_members = Member.objects.all()
                    else:
                        valid_members = Member.objects.filter(group_name=group_name)
                else:
                    # Raise 404 if no members are found given the group name
                    raise Http404("No member in group '%s'" %
                                  form.data.get('group', ''))
                # Lucky draw
                lucky_member = random.choice(valid_members)
                # ...
            

            form.is_valid() 可以驗証每個欄位的資料是不是正確的。

            我們也順便把 /draw 加上 template 吧。

            {# draw_member/draw.html #}
            {% extends 'draw_member/base.html' %}
            {% block title %}抽籤結果{% endblock title %}
            {% block content %}
            <h1>抽籤結果</h1>
            <p>{{ lucky_member.name }}(團體:{{ lucky_member.group_name }}</p>
            {% endblock content %}
            
            # draw_member/views.py
            def draw():
                # ...
                return render(request, 'draw_member/draw.html', {
                    'lucky_member': lucky_member
            

            更多 Forms 的介紹一樣參考官網

            做完的成品在 Github 上,參考 README 就可以設定好環境了。

            這樣就把 Django 最基本的 Model, View, Template, Form 幾個大部份體驗一遍了。可以感覺出來 Django 提供的功能比 Flask 多很多,但也代表要花更多的時候學習使用它。其實改寫到最後我們的 code 非常少,可以為了結構化的 code 還比較多。

            當然這不代表就學會 Django 了。最後來介紹幾個可以接續學習的 Django 資源:

          • 《為程式人寫的 Django Tutorial 》是個真正從零到一的 30 天學習規劃(雖然我學了好幾個月 T___T),有了這個抽籤程式的概念再去讀一次應該會更清楚整個 Django 的設計。作者:Tzu-ping Chung (@uranusjr)
          • Mastering Django: Core, the successor to The Django Book last updated in 2009, is the definitive guide to Django targeting the latest Django version 1.8 at the time of writing.
          • 更多的 Django 技能樹選擇請見 TP 的 lesson 30

            Details

            跟 Flask 一樣,底下記錄一些細節或改善等等為了避免篇幅過長(已經太長了)而移至此的段落。

            Raw SQL

            在介紹 Django Model 的時候直接用了 ORM,但實際上 Django 是可以寫 raw SQL 了,而且還有「聰明版」的 raw SQL 能夠拿回對應的 model object。馬上來看怎麼回事。

            先來看聰明版的 raw SQL,使用 Model.objects.raw 拿回所有團體是 K-ON 類的成員。

            >>> list(Member.objects.raw("""
            ... SELECT id, name, group_name
            ... FROM draw_member_member
            ... WHERE group_name LIKE 'K-ON%%'
            ... """))
            [<Member: 平沢 唯 of K-ON!>,
             <Member: 秋山 澪 of K-ON!>,
             <Member: 田井中 律 of K-ON!>,
             <Member: 琴吹 紬 of K-ON!>,
             <Member: 中野 梓 of K-ON!>]
            

            會回傳一個 RawQuerySet,裡面其實也是 Member objects,這是靠 Django 去認對應的 primary key,也就是說在 raw() SQL query 裡一定要放 primary key。注意那個 % 需要被 escape 因為 raw() 的 SQL query 是能放參數的(就像 Python 內建 str %-formatting)。

            不過我們怎麼知道 Member 是存在哪個 table 呢?預設是 <app>_<model>,但資訊在 meta options 裡的 db_table,也能被覆寫。

            >>> Member._meta.db_table
            'draw_member_member'
            

            因為 Member 裡面有像 name、group_name 等欄位,在下 query 的時候不一定都會寫在 SELECT 裡面把拿值回來,那麼這些欄位就是 deferred 狀態,只有在真的拿值時才會去跟 database 要。一般使用不會有感覺兩者的差異。

            >>> m = list(Member.objects.raw(
            ...     "SELECT id FROM draw_member_member"
            ... ))[0]
            >>> type(m)
            draw_member.models.Member_Deferred_group_name_name
            >>> m.get_deferred_fields()
            {'group_name', 'name'}
            

            但我就是不想用 ORM,速度慢,也沒辦法寫複雜的 query(戰)。這就回歸到最傳統的 database connection, cursor 這些概念,就像沒有 SQLAlchemy 的 Flask。

            >>> from django.db import connection
            >>> c = connection.cursor()
            >>> list(c.execute("""
            ... SELECT name
            ... FROM draw_member_member
            ... WHERE group_name LIKE %s
            ... """, ["K-ON"]))
            [('平沢 唯',), ('秋山 澪',), ('田井中 律',), ('琴吹 紬',), ('中野 梓',)]
            >>> list(c.execute("""
            ... SELECT member_id, time
            ... FROM draw_member_history
            ... LIMIT 3
            ... """))
            [(8, datetime.datetime(2015, 10, 5, 17, 36, 41, 608078, tzinfo=<UTC>)),
             (11, datetime.datetime(2015, 10, 5, 17, 37, 26, 164830, tzinfo=<UTC>)),
             (11, datetime.datetime(2015, 10, 5, 17, 37, 37, 483697, tzinfo=<UTC>))]
            

            Here you go.

            Better QuerySet

            看過了 raw SQL 之後,我們來想想 ORM 的改善吧。雖然說每次要查詢的時候像寫 SQL 一樣把 query 組合出來也可以,但用 ORM 的好處應該是能把這些實作細節跟「包裝起來」。例如最近 n 次抽籤記錄、所有成員的團體名稱(目前是寫死在 DrawForm 裡面)。

            這時候就可以把常用的 query 變成一個 method,例如最近 10 次抽籤記錄就只要用 History.objects.recent(10) 就可以了。

            這其實有很多做法,像是寫一個 classmethod、Override default Manager、Override default QuerySet。哪個方法比較好呢?在 StackOverflowmail list 都有討論。基本上都能達到相同的效果,但後兩者的做法是比較偏好的,因為 Manager(or QuerySet for Django 1.7+) 負責處理 model 對應到的 database table 等級的操作,但 classmethod 應該是處理已經從 table row 中拿出的一個 model object 相關的操作。如果把同樣性質的 code 放在一起,就應該使用 Manager(QuerySet)。

            而且 TP 也在 Gitter 上開示了,就是這樣(結案)。來改寫 model。

            # draw_member/models.py
            class MemberQuerySet(models.QuerySet):
                def unique_groups(self):
                    return self.values_list('group_name', flat=True).distinct()
            class HistoryQuerySet(models.QuerySet):
                def recent(self, n):
                    return self.order_by('-time')[:n]
            class Member(models.Model):
                # ...
                objects = MemberQuerySet.as_manager()
            class History(models.Model):
                # ...
                objects = HistoryQuerySet.as_manager()
            

            在 Member 我們定義了一個 unique_groups 拿回所有團體的名稱;在 History 定義了 recent 拿出按時間排序最前面 n 個。新定義的 QuerySet.as_manager() 就取代掉本來的 Model.objects

            接著來改寫 view 把之前寫的 query 換掉。

            #draw_member/views.py
            def history(request):
                # ...
                recent_draws = History.objects.recent(10)
                # ...
            

            這樣就簡潔一點。再來順便把 form 改得比較彈性,不要把團體名寫死。

  •