Using jinja2 as django template engine

Django 1.8 以後已經可以選擇使用自家的 template engine 或是使用 jina2 template engine 了,而且是可以混用,不限制你只能使用其中一種。

第一步是先安裝 jinja2:

pip install jinja2

第二步是在 settings 裡的 TEMPLATES 裡增加 jinja2 的設定:

TEMPLATES = [
    # 省略 django template 的設定
    {
        'NAME': 'jinja2',
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        'DIRS': [
            # insert more TEMPLATE_DIRS here
            # join(BASE_DIR, 'templates'), 
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            'environment': 'your_project.jinja2.environment',
        },
    },
]

 

第三步,是在 your_project 下建立 jinja2.py ,內容:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import    # 使用 absolute_import 很重要!!!
import logging
from django.utils import translation
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import reverse
import jinja2


logger = logging.getLogger(__name__)


def environment(**options):
    env = jinja2.Environment(
        extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'],
        **options)
    env.globals.update({
        'static': staticfiles_storage.url,
        'url': reverse,
    })
    env.install_gettext_translations(translation)    # 使用 django 的 i18n/l10n
    return env

在這裡,我遇到一個奇怪的狀況,python 2 一直告訴我 jinja2 沒有 Environment,後來看了這篇,才知道是 absolute/relative import 問題,使用 absolute_import 以確保 import 的 jinja2 是正確的,而不是 django 的 jinja2。我想,python 3 應該不會有這問題。

django template 是使用 load 載入模組,jinja2 則需要在 Environment 初始化時就定義。

第四步,是在繼承 TemplateView 的類別裡,加入

template_engine = 'jinja2'

,然後在 your_app 下建立 jinja2 路徑,將 template 檔案放在那裡即可。

class Jinja2Test(TemplateView):
    template_name = 'jinja2test.html'  # template file 的名稱是 jinja2test.html ,檔案必須放在 your_app/jinja2 下。
    template_engine = 'jinja2'  # 表示要使用 jinja2 template engine,這個 jinja2 是定義在 settings 裡的 NAME

    def get_context_data(self, **kwargs):
        ctx = super(Jinja2Test, self).get_context_data(**kwargs)
        ctx['foo'] = 'bar'
        ctx['hello'] = 'world'
        return ctx

參考資料:

如何客製 Django package 的 locale?

在 Django 裡使用了某個 package,但該 package 沒有自己所用的 locale 時,該怎麼做呢?

以下以 django-mptt 為例:

  1. 先確認 site-packages 下 django-mptt 的目錄名稱,在安裝 django-mptt 以後,實際上是 mptt,所以在專案目錄下建立 mptt/locale 。
  2. 到 site-packages/mptt 下,執行 django-admin makemessages –locale=zh_TW
  3. 將 site-packages/mptt/locale/zh_TW 搬移到專案目錄下的 mptt/locale
  4. 使用 poedit 或其他工具進行翻譯
  5. 調整 django settings 裡的 LOCALE_PATHS,例如:
    from os.path import dirname, join
    BASE_DIR = dirname(dirname(dirname(__file__)))
    # ...略...
    LOCALE_PATHS = [
        join(BASE_DIR, 'locale'),
    
        # customize translations which package is in site-packages
        join(BASE_DIR, 'mptt', 'locale'),
    ]
    

這樣就大功告成了。

pdfkit/wkhtmltopdf

wkhtmltopdf 是一個可以將網址或是 HTML 檔案轉換為 PDF 的程式。

pdfkit 則是一個 python package (或 library),用來將網址或是 HTML 轉換為 PDF,底層用的是 wkhtmltopdf 。

django-pdfkit 則是給 Django 使用的 app,主要使用 pdfkit ,提供了 PDFView 。要提供產出 PDF 的網頁,只要寫一個繼承自 PDFView 的 View,撰寫 template ,就可以了。

以下是幾個試過以後的心得:

  1. 內部處理順序是先使用 Django template 輸出為 HTML,再用 pdfkit 輸出為 PDF。
  2. 預設用 PATH 去找 wkhtmltopdf,但如果有指定 WKHTMLTOPDF_BIN 這個環境變數的話,就用這裏面的。
  3. 在網址加入 html 參數,表示顯示 HTML,例如:http://server/show_pdf/?html
  4. 在網址加上 inline 參數 ,表示直接顯示 PDF,而非下載,例如:http://server/show_pdf/?inline
  5. 要自訂 pdfkit 使用 wkhtmltopdf 的參數,在繼承的 View 裡加入 pdfkit_options (型態為 dict) 或是自訂 get_pdfkit_options method。
  6. 要自訂輸出的檔名,可以在繼承的 View 裡加入 filename 或是自訂 get_filename method。
  7. wkhtmltopdf 預設不使用 print media-type ,所以在 css 裡加的 print media-type 相關樣式都沒用,除非自訂 pdfkit_options ,加入 {“print-media-type”: “”}。雖然加了 print-media-type 可以強制讓 wkhtmltopdf 參考 print media-type,但是一般可以在網路上查到,使用 print media-type 在每頁加頁次的方法是行不通的,一樣要自訂 pdfkit_options 才行 (參考 wkhtmltopdf -h 說明):{ ‘footer-center’: ‘[page] / [toPage]’}。其他還有調頁面邊界、紙張大小的,也都是要看 wkhtmltopdf 的說明來調整 pdfkit_options 。
  8. bootstrap 3 本身就有支援 print media-type ,直接使用就會有不錯的效果。如果要更漂亮,可以參考 Natshah/bootstrap-print: To manage print media for Twitter Bootstrap v3.
  9. 表格跨頁標題主要是使用 table/thead/tbody 跟調整 css,之前參考的網頁找不到了,這裡直接貼相關的 css 跟 html:
    /* CSS */
    @media print {
      table {display:table;}
      thead {display:table-header-group!important;page-break-after:avoid!important;}
      tbody {display:table-row-group!important;page-break-after: auto!important;}
      tr, img {display:table-row!important;page-break-inside:avoid!important;page-break-after: auto!important;}
      td, th {display:table-cell!important;}
    }
    
    <!-- HTML -->
    <table class="table table-bordered">
      <thead>
        <tr>
            <th class="text-center">編號</th>
            <th class="text-center">姓名</th>
        </tr>
      </thead>
      <tbody>
        <tr>
            <td>1</td>
            <td>王小明</td>
        </tr>
      </tbody>
    </table>
    

Python profiling decorator

前幾天想知道我 Django 程式裡某段函式的瓶頸,所以查了 Django 怎麼做 profiling 。是有查到有 jazzband/silk: Silky smooth profiling for Django 這個 package ,但是有點太大。如果是用常找到的

python -m cProfile xxx.py xxx.py

,Django 程式又不太適合。畢竟我只是想查某個函式而已。所以後來查到這兩個 decorator:

使用上很簡單,程式放進去,在想做 profiling 的函式前加上 decorator 就可以了。

我用的是第一個,在加上 decorator,執行過程式(應該說是瀏覽網頁)以後,在程式當前資料夾裡會找到 .profile 的檔案,為了後續方便說明,假設產生出來是 func.profile 。有這個檔案以後,就可以用

python -m pstats func.profile

開啟,開啟以後,是 pstats 的 shell ,一般要查哪個地方花的時間最多,會用

sort time

依照執行花費時間來排序,再用

stats 10

列出前十個花費時間最多的函式。

參考資料:

升級 daphne 到 1.2.0 後導致連線卡住

使用 django channels 得使用 daphne 來處理 asgi (websocket/long poll http) 請求,我遇到的情況是從 1.1.0 升級到 1.2.0 以後,整個 django 應用就無法接受服務。

查了很久,看程式也覺得應該沒問題,該處理的 request ,daphne 都有轉送給 channels 去處理,而 channels 也都有處理。心裡想,這應該是個大問題,早該有人回報吧。但用 google 查詢都沒查到,今天心血來潮查了 Daphne 在 github 上的 closed issues,總算是找到了:daphne==1.2.0 hangs forever · Issue #105 · django/daphne

解法也很簡單,升級 asgi_redis/asgi_ipc 到新版就可以了。下次升級 daphne 時,得注意。

Django queryset 對日期時間欄位的額外設定

Django queryset 對日期時間的處理已經很完備了,可以透過使用 __year 或 __month 等方式來找到是某年或某月的紀錄。

這兩天碰到的狀況是,資料是 MySQL 時,日期時間的比對 (__year / __month) 失效了。仔細看過文件以後,才發現 MySQL 需要事先設定,使用 mysql_tzinfo_to_sql 載入時區表格才行。

This function performs time zone conversions directly in the database. As a consequence, your database must be able to interpret the value of tzinfo.tzname(None). This translates into the following requirements:

在終端機 (shell) 裡,輸入下列指令:

mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql

接著重新啟動 MySQL 伺服器即可。

mysql_tzinfo_to_sql 的用法不只一種,我選擇的是最簡單的用法。

django-channels websocket 與 nginx

django-channels 的佈署指南,裏面的幾個重點:

  1. 先安裝 redis,再用 pip 安裝 asgi_redis ,然後將 settings 裡 CHANNEL_LAYERS 的 “BACKEND” 改為 asgi_redis.RedisChannelLayer ,這樣效能會比較好些。
  2. 執行 worker :
    python manage.py runworker
  3. 在 wsgi.py 的同個資料夾,新增 asgi.py ,裏面放
    import os
    from channels.asgi import get_channel_layer
    
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "my_project.settings")
    
    channel_layer = get_channel_layer()
  4. 執行 ASGI server :
    daphne your_project.asgi:channel_layer

    ,daphne 是在安裝 channels 時,會跟著裝上的。使用了 daphne 以後,就可以不需要 gunicorn 了,至於 uwsgi ,因為我沒在用,所以沒有深入研究。

runworker 跟 daphne 的部份,可以使用 supervisor 或者是寫 upstart script ,讓他們自動啟動。除了這些重點以外,nginx 的設定也需要調整,設定不多,只有幾行,這可以參考這兩篇文章:

大致的設定如下:

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

upstream app_server {
    server 127.0.0.1:8000 fail_timeout=0;
}

server {
    include mime.types;
    default_type application/octet-stream;
    access_log /var/log/nginx/access.log combined;
    sendfile on;

    listen 80;
    client_max_body_size 4G;

    # set the correct host(s) for your site
    server_name example.com;

    keepalive_timeout 5;

    # path for static files
    location /static/ {
        alias /srv/app/site/static/;
    }

    location /media/ {
        alias /var/app/media/;
    }

    location / {
        # checks for static file, if not found proxy to app
        try_files $uri @proxy_to_app;
    }

    location @proxy_to_app {
        proxy_buffering off;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;

        proxy_pass   http://app_server;

        # For websocket
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }

    error_page 500 502 503 504 /500.html;
    location = /500.html {
        root /srv/app/site/static;
    }
}

我在套用這些設定以後,websocket 仍然無法順利連上,找了好半天,才找到這篇:Websockets fail to work after upgrading to 1.0.0 · Issue #466 · django/channels  ,看完才知道,channels/daphne 在升級到 1.0 以後,程式裡的 websocket consumer 必須要送出

{"accept": True}

才行。這部份可以參考 1.0 的 release note ,也可以參考新的 Getting Start。不過我在參考 Getting start 時,居然沒注意到這行,也是我太大意了。

Automate Django createsuperuser in Ansible

Ansible 有個 django_manage 模組,可以很方便的執行 django 裡的 manage.py,但是受限於 createsuperuser 的關係,並沒辦法在建立 superuser 的同時,一併設定密碼。

一般網路上的解決方法是自己寫個小 python 腳本 (可以看這篇 How to automate createsuperuser on django? ),丟給 shell 去執行。我是想到可以利用 manage.py 提供的 changepassword 並搭配 expect 來做,大致上是這樣子:

另外也做了避免重複建 superuser 的機制。

Django model 的 validate

同事問,為什麼用 model 建立 instance 以後,呼叫 save 沒有觸發 validate?

上網找了一下,大致有兩種方法:

  1. 用 ModelForm 的 is_valid() 做檢查,Correct Way to Validate Django Model Objects?
  2. 覆寫 model 的 save(),Django model mixin to force Django to validate (i.e. call `full_clean`) before `save`

事情沒有這樣結束,因為很好奇 Django 的作法,就去追 Code。

如果是走 is_valid() :

  1. forms/models.py:ModelForm 繼承自 BaseModelForm,而 BaseModelForm 又繼承自 BaseForm,這個檔案裡,沒有看到 is_valid
  2. forms/forms.py:
    1. BaseForm.is_valid() 很簡單的只回傳 self.is_bound and not self.errors ,self.is_bound 跳過不看,self.errors 是一個 property,裏面呼叫了 self.full_clean()
    2. self.full_clean() 呼叫了 self._clean_fields(), self._clean_form() 與 self._post_clean() ,關鍵在 self._post_clean() 。BaseForm 裡的 _post_clean() 是空的,讓繼承 BaseForm 的類別去決定。
  3. forms/models.py: 回頭看 BaseModelForm._post_clean(),這裡呼叫了 self.instance.full_clean() ,並處理 ValidationError 。instance 就是 model 類別所創出的實體,Model 繼承自 ModelBase 所以接著看 ModelBase 類別。
  4. db/models/base.py: ModelBase 的 full_clean() 呼叫了 self.clean_fields(), self.clean() 與 self.validate_unique ,並且在這裡處理了 ValidationError 例外,最後判斷了 errors 裡是否有東西,有的話,丟出 ValidationError。先看 self.clean_fields() ,函式裡去取得定義在類別裡的欄位,並且呼叫每個欄位 (Field) 類別的 clean() 函式。如果有錯誤就丟出 ValidationError()
  5. db/models/fields/__init__.py:Field 類別定義在這個檔案裡,裏面的 clean() 呼叫了 self.validate() 、self.run_validators()

至此,ModelForm 的路徑就確定了。

來看看第二個方法,他是要求 Model 類別除了繼承 Django Model 之外,再繼承 ValidateModelMixin 這個自訂類別以覆寫 save() ,看看裏面的 save(),就只是多呼叫了 full_clean() 來檢查,也就是走上述步驟 4 以後的路線。

Django 官方網站不建議直接去呼叫 full_clean() 這件事,不過找了一下,也沒找到什麼更好的解法~

P.S.