hama_duのブログ

ノンジャンル記事置き場

Google App Engine用フレームワークtipfyの紹介とチュートリアル その2

このエントリについて

Google App Engine用に作られたフレームワーク「tipfy」の使い勝手がよかったので
チュートリアル形式で簡単に紹介をしたいと思います。

前回の続きの内容になってます。
その1:http://d.hatena.ne.jp/hama_DU/20101206/1291644589

前回まで

前回はtipfyの導入と、管理アプリとして、

  • テストデータを追加する「internal/addentry」
  • データストアを削除する「internal/init」

を作り、それぞれアクセスできるようにしました。

今回は

  • エントリ一覧機能
  • エントリ追加機能

を作ります。

エントリ一覧画面を作る

エントリを扱うためのアプリ「entry」をappsに追加します。
hello_worldかinternalからコピペするのがいいでしょう。

config.pyの編集

config.pyのapps_installedに忘れずに追加しておきます。

    'apps_installed': [
        'apps.hello_world',
        'apps.internal',
        'apps.entry',
    ],
リクエストハンドラを作る

apps/entry/handlers.pyを編集します。
「一覧」機能なので、リクエストハンドラ名はListHandlerとでもしましょう。

class ListHandler(RequestHandler):
  def get(self):
    entry_list = Entry.all().fetch(100)
    context = {
      'entry_list': entry_list,
    }
    return render_response('entry/list.html', **context)

モデル Entryから全てのデータを取得し、先頭の100件をとって entry_list に代入しています。
render_response の引数指定方法についてですが、

    return render_response('entry/list.html', var_x=x, var_y=y, ....)

このような感じに (変数名 = 値) のペアをたくさんつなげて指定することもできるのですが、
これは次のように、辞書オブジェクトを渡してやることもできます。

    context = {
        'var_x': x,
        'var_y': y,
    }
    return render_response('entry/list.html', **context)

この方が、テンプレートに渡したい引数が増えても対応が楽ですし、
何より見やすいのでオススメです。

テンプレートを作る

新しくtemplates以下にentryフォルダを用意し、list.htmlというファイルを作成し、出力時のテンプレートとします。

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
      <table border="1">
        <tr>
          <th>title</th>
          <th>created</th>
        </tr>
        {% for entry in entry_list %}
          <tr>
            <td>{{ entry.title }}</td>
            <td>{{ entry.created_at }}</td>
          </tr>
        {% endfor %}
      </table>
    </body>
</html>

{% for %} と {% endfor %} で囲まれた部分がforループになっていて、
{% for item in list %} と、文法的にpythonと同じように使うことができます。
このように、jinja2では{% 〜〜 %} というかたちで様々な制御構文をテンプレート上で扱うことができます。
詳しくは
http://jinja.pocoo.org/templates/
に使える文法の一覧が載っています。

内容的には、データストアから取得したentry_listに入っているEntryオブジェクトに対して、
タイトルと作成日時をテーブル状に表示させるものになっています。

ルーティング設定

apps/entry/urls.py に忘れずに記述します。

    rules = [
        Rule('/entry/list', endpoint='entry/list', handler='apps.entry.handlers.ListHandler'),
    ]
テスト

http://localhost:8080/entry/list
にアクセスして試してみましょう!

テンプレートを修正

さて、ここで一つ問題が。データストアが空っぽのときに entry/list を表示させようとすると、
表のヘッダだけが表示されるというなんとも間の抜けた感じになってしまいます。
entry_list が 空っぽ( )なので、{% for entry in entry_list %} の中身が一度も実行されないからです。
ここで、entry_listが空の時は、「現在、まだエントリはありません」と表示させるように
テンプレートを改造したいと思います。

ここでは、{% if %} 構文を使って((さっきのリファレンスを見てください))、entry_listが空であるかどうかを判定します。
pythonでは、真偽値として空のリスト()を False として扱うので、entry_listが空であるかどうかは次のように書けます。

{% if not entry_list %}
  # entry_listは空
{% else %}
  # 処理
{% endif %}

これを使って templates/entry/list.html を書き換えると、次のようになりました。

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
      {% if not entry_list %}
        現在、まだエントリはありません
      {% else %}
        <table border="1">
          <tr>
            <th>title</th>
            <th>created</th>
          </tr>
          {% for entry in entry_list %}
            <tr>
              <td>{{ entry.title }}</td>
              <td>{{ entry.created_at }}</td>
            </tr>
          {% endfor %}
        </table>
      {% endif %}
    </body>
</html>

エントリ登録画面を作る

エントリ一覧の機能作成が終わったので、次はエントリの登録機能を作ります。
一般的に、登録フォーム機能はWebサービスを作る上でとてもメンドクサイ(笑)のですが、
tipfyにはフォームを扱う便利なプラグインが用意されています。
さっそく、インストール方法と使い方を見ていきましょう。

tipfy.ext.wtformsをインストール

/path/to/project/buildout.cfg をエディタで開いてみてください。
途中にこんな感じの設定が書かれている部分があるかと思います。

# Define the libraries. We include all official extensions by default, but you
# can remove the ones you won't use. The only required package is tipfy.
eggs =
    babel
    tipfy
    tipfy.ext.appstats
    tipfy.ext.auth
    tipfy.ext.blobstore
    tipfy.ext.db
    tipfy.ext.debugger
    tipfy.ext.i18n
    tipfy.ext.jinja2
    tipfy.ext.mail
    tipfy.ext.session
    tipfy.ext.taskqueue
    tipfy.ext.xmpp

ここには、tipfy本体をビルドする時に一緒にインストールする拡張機能が書かれています。
この末尾に、

# Define the libraries. We include all official extensions by default, but you
# can remove the ones you won't use. The only required package is tipfy.
eggs =
    babel
    tipfy
    tipfy.ext.appstats
    (省略)
    tipfy.ext.taskqueue
    tipfy.ext.xmpp
    tipfy.ext.wtforms

と、wtformsを追加して、本体をビルドし直せば拡張機能がインストールされます。
ビルド自体はプロジェクト直下(/path/to/project)で以下の手順で行います。

$ python2.5 bootstrap.py --distribute
$ bin/buildout


実は、wtforms自体は
http://wtforms.simplecodes.com/
で単品提供されているpython用のライブラリで、tipfyのプラグインはこの機能に加えて

  • ファイルアップロード(input type="file")
  • ReCaptcha*1
  • CSRF*2対策

を扱う機能が追加されています。

フォームをhandlers.pyで定義する

apps/entry/handlers.py に次のコードを追加します。

from tipfy.ext.wtforms import Form, fields, validators

class FindForm(Form):
  title = fields.TextField('タイトル', [
    validators.required(),
    validators.Length(max=40),
  ])
  content = fields.TextAreaField('内容', [
    validators.required(),
  ])

Formを継承したフォームFindFormを作りました。
FindFormにはフィールドが二つ定義されています。

  • タイトルフォーム (input type="text")
  • 内容入力フォーム (textarea)
  field_name = fields.XxxxField('ラベル', [入力検証のリスト])

とすることで、フォームのフィールドを定義できます。
XxxxFieldの部分はフォームのタイプを表していて、

  • TextField (input type="text")
  • TextAreaField (textarea)

の他にも、

  • BooleanField (input type="checkbox")
  • HiddenField (input type="hidden")
  • PasswordField (input type="password")
  • SelectField (select)

などが用意されています。

フィールドの定義方法の詳細に関しては、公式ドキュメント
http://wtforms.simplecodes.com/docs/0.6.1/fields.html
を参照するのがいいでしょう。

リクエストハンドラを作る

フォームをテンプレートで使えるようにするために、
handlers.pyに以下のコードを追加します。

class EditHandler(RequestHandler):
  def get(self):
    context = {
      'form': FindForm(),
    }
    return render_response('entry/edit.html', **context)
ルーティング設定
    rules = [
        Rule('/entry/list', endpoint='entry/list', handler='apps.entry.handlers.ListHandler'),
        Rule('/entry/edit', endpoint='entry/edit', handler='apps.entry.handlers.EditHandler'),
    ]
テンプレートを作る

templates/entry/edit.html を作成し、以下の内容を追記します。

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
      <form method="post" action="{{ url_for('entry/edit') }}" enctype="multipart/form-data">
        <p>
          <label for="{{ form.title.id }}">{{ form.title.label.text }}</label><br/>
          {{ form.title }}
        </p>
        <p>
          <label for="{{ form.content.id }}">{{ form.content.label.text }}</label><br/>
          {{ form.content }}
        </p>
        <p>
          <input type="submit" value="投稿!"/>
        </p>
      </form>
    </body>
</html>

{{ form.フィールド名 }} とすることでフォーム本体を表示できます。
また、フィールドを作るとき第一引数に指定したラベルを表示するには、 {{ form.フィールド名.label.text }} とします。

また、フォームのアクション先を指定する部分では、tipfyに用意されている関数「url_for」を用いています。
この関数は、urls.py の endpoint に指定した文字列を実際のURLに変換してくれるものです。
例えば今回の場合、

form action="{{ url_for('entry/edit') }}"

と、'entry/edit' を指定しているので、endpoint に 'entry/edit' を指定した

        Rule('/entry/edit', endpoint='entry/edit', handler='apps.entry.handlers.EditHandler'),

このルールがヒットし、

form action="/entry/edit"

に置き換わります。

試しに実行

http://localhost:8080/entry/edit
にアクセスして、出力された内容を確認してみましょう。投稿フォームが表示されているでしょうか?

試しに、投稿ボタンを押してみましょう。すると

werkzeug.exceptions.MethodNotAllowed
MethodNotAllowed: 405: Method Not Allowed

こんなエラー(例外)が表示されたはずです。

これは、宛先URLは正しくルーティングされ、リクエストハンドラに到達したものの、
メソッドが足りないため実行できなかった時に起こるエラーです。

今回、フォームを送信する際は

      <form method="post" action="{{ url_for('entry/edit') }}" enctype="multipart/form-data">

と、method に ”post” を指定しました。しかし、リクエストハンドラでは、

class EditHandler(RequestHandler):
  def get(self):
    context = {
      'form': FindForm(),
    }
    return render_response('entry/edit.html', **context)

getメソッドしか用意されていません。ここで、postメソッドを追加してやれば
エラーが起きず、フォーム送信を受けた時の処理を記述できます。

フォームの処理

EditHandlerにpostメソッドを追加します。

from datetime import datetime, date, time

class EditHandler(RequestHandler):
  def post(self):
    sentform = FindForm(self.request)
    context = {
      'form': sentform,
    }
    if not sentform.validate():
      return render_response('entry/edit.html', **context)

    Entry(
      title=sentform.title.data,
      content=sentform.content.data,
    ).put()
    return render_response('entry/complete.html')

リクエストハンドラでは、 self.request にリクエストの情報が含まれています。
これを丸ごとフォームのコンストラクタに渡してあげることで、
フォームの入力状況を再現することができます。
例えば、タイトルに入力した内容を知りたければ、

  sentform = FindForm(self.request)
  print sentform.title.data

などできます。しかし、これは内容未チェックのデータです。
ユーザはブラウザ上のフォームでの制限があっても、任意の内容を送ることができるので、
この段階でそのままデータを登録することはできません。
そこで、フォームの内容の検証(バリデーション)を行う必要があります。
wtformsでは、次のメソッドを実行するだけでOKです。

  form.validate()

結果が True だった場合は、入力内容が正しかった、ということになります。
もし False であった場合は、入力内容が間違っているので、
その旨をユーザに知らせるためにもう一度入力画面を表示させる必要があるでしょう。

入力内容が正しかったとき

入力内容が正しかった場合は、実際に Entryモデルのデータをデータストアに挿入します。
そして、ユーザには投稿完了画面(/templates/entry/complete.html)を表示させます。

入力内容が間違っていたとき

form.validate() を実行し結果が False だったとき、
form.fieldname.error にはどのようなエラーが起きたか、という情報がリストになって含まれています。
なので入力内容が間違っていたときは、投稿内容入力画面に戻して、
そのエラーの情報をユーザに表示させるようにします。

/templates/entry/edit.html を以下のように書き換えます。

      <form method="post" action="{{ url_for('entry/edit') }}" enctype="multipart/form-data">
        <p>
          <label for="{{ form.title.id }}">{{ form.title.label.text }}</label><br/>
          {{ form.title }}
          {% if form.title.errors %}
            <ul>
                {% for error in form.title.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
          {% endif %}
        </p>
        <p>
          <label for="{{ form.content.id }}">{{ form.content.label.text }}</label><br/>
          {{ form.content }}
          {% if form.content.errors %}
            <ul>
                {% for error in form.content.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
          {% endif %}
        </p>
        <p>
          <input type="submit" value="投稿!"/>
        </p>
      </form>

とりあえずリストにして表示させてみました。
ここで、タイトルや内容を空にして投稿ボタンを押してみると、

・This field is required.

というエラーが表示されたかと思います。ばんざい!

時間を調整する

試しに、データを追加してみて、entry/listを表示させてみると、
時間が9時間ほどずれていることが分かると思います。
実は、pythonの時間は協定世界時(UTC)しか用意されていなくて、
各国のローカル時間を表すには「UTCからどのぐらいずれているか」を表すクラスを作らなければなりません。

「日本時間」を表す、tzinfoを拡張したクラスを新たに作成します。
場所は timezone/jst.py とでもしておきます。

from datetime import datetime, date, time, tzinfo, timedelta

class jst(tzinfo):
  def utcoffset(self, dt):
    return timedelta(hours=9)

  def dst(self, dt):
    return timedelta(0)

  def tzname(self, dt):
    return 'jst'

utcoffsetは、UTCからどれだけ進んでいるか、
dstは夏時間用の調整、
tznameはこの時間設定の名前を返すようにします。


また、「協定世界時」を表すクラスも作っておきます。(timezone/utc.py)

from datetime import datetime, date, time, tzinfo, timedelta

class utc(tzinfo):
  def utcoffset(self, dt):
    return timedelta(0)

  def dst(self, dt):
    return timedelta(0)

  def tzname(self, dt):
    return 'utc'

Google App Engineのデータストアでは、
タイムゾーンを変更したdatetimeオブジェクトを入れようとしても、
強制的にUTCになってしまうため、日本時間を表示させるには

  • UTCで保存されたデータを取り出し
  • 日本時間に変更し
  • 表示させる

という流れになります。


ListHandlerのgetメソッドを次のように書き換えます。

from timezone.jst import jst
from timezone.utc import utc

class ListHandler(RequestHandler):
  def get(self):
    entry_list = Entry.all().fetch(100)
    context = {
      'entry_list': entry_list,
      'utc': utc(),
      'jst': jst(),
    }
    return render_response('entry/list.html', **context)

UTCとJSTのオブジェクトをコンテキストに含んでおいて、

      {% for entry in entry_list %}
        <tr>
          <td>{{ entry.title }}</td>
          <td>
          {% if entry.created_at %}
            {{ entry.created_at.replace(tzinfo=utc).astimezone(jst) }}
          {% endif %}
          </td>
        </tr>
      {% endfor %}

テンプレートでこのように使います。
これで、世界標準時刻を日本時間にして表示させることができました。

テンプレートの重複を防ぐ

テンプレートマクロ

フォームのテンプレートファイル /templates/entry/edit.html をよく見ると、

        <p>
          <label for="{{ form.title.id }}">{{ form.title.label.text }}</label><br/>
          {{ form.title }}
          {% if form.title.errors %}
            <ul>
                {% for error in form.title.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
          {% endif %}
        </p>

ここと、

        <p>
          <label for="{{ form.content.id }}">{{ form.content.label.text }}</label><br/>
          {{ form.content }}
          {% if form.content.errors %}
            <ul>
                {% for error in form.content.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
          {% endif %}
        </p>

この部分が、表示させる内容が違うだけで、
構造は全く同じであることが分かります。
この部分をすっきり書くために、jinja2の機能「テンプレートマクロ」を導入します。


マクロ用のファイル「templates/_form_macros.html」を作成します。

{% macro form_field(field) %}
    <label for="{{ field.id }}">{{ field.label.text }}</label><br/>
    {{ field }}
    {% if field.errors %}
      <ul>
          {% for error in field.errors %}
          <li>{{ error }}</li>
          {% endfor %}
      </ul>
    {% endif %}
{% endmacro %}

ひとつのフィールドを出力する部分をまるごと持って来て、
フィールドを引数に持つマクロを作ってみました。form_field というのがマクロの名前で、
テンプレートファイルからインポートすることで使えるようになります。

{% from '_form_macros.html' import form_field %}

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
      <form method="post" action="{{ url_for('entry/edit') }}" enctype="multipart/form-data">
        <p>
          {{ form_field(form.title) }}
        </p>
        <p>
          {{ form_field(form.content) }}
        </p>
        <p>
          <input type="submit" value="投稿!"/>
        </p>
      </form>
    </body>
</html>

すっきりまとめることができました。
他にも、ラベル部分のみ表示するマクロ、エラー部分のみ表示するマクロ等を用意しておくと便利でしょう。
例えばこんな風にしておけば、

{% macro form_field(field) %}
  {{ form_label(field) }}<br/>
  {{ field }}
  {{ form_error(field) }}
{% endmacro %}

{% macro form_label(field) %}
  <label for="{{ field.id }}">{{ field.label.text }}</label>
{% endmacro %}

{% macro form_error(field) %}
  {% if field.errors %}
    <ul>
        {% for error in field.errors %}
        <li>{{ error }}</li>
        {% endfor %}
    </ul>
  {% endif %}
{% endmacro %}

ラベルだけ自分で書きたいとき、エラー表示をカスタマイズしたい時でも対応できます。

ブロックを使う

今回、機能を作るにあたってテンプレートファイルを3つほど使いましたが、
その3つのファイルは共通して

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
        <!-- 内容 -->
    </body>
</html>

という形になっており、重複しています。
この重複を解消するため、jinja2の便利な機能が使えます。


まず、「templates/layout.html」に全体のレイアウトを定めておきます。

<!DOCTYPE html>
    <head>
        <title>tipfy sample app!</title>
    </head>
    <body>
      {% block body %}
      {% endblock %}
    </body>
</html>

大枠のみを指定し、中身は実際の処理で使われるテンプレートファイルに任せる、といった格好です。
このレイアウトを使うには次のようにします。

{% extends 'layout.html' %}

{% block body %}
  {% if not entry_list %}
    現在、まだエントリはありません
  {% else %}
    <table border="1">
      <tr>
        <th>title</th>
        <th>created</th>
      </tr>
      {% for entry in entry_list %}
        <tr>
          <td>{{ entry.title }}</td>
          <td>{{ entry.created_at }}</td>
        </tr>
      {% endfor %}
    </table>
  {% endif %}
{% endblock %}

エントリ一覧画面を例にしてみました。
先ほど作ったレイアウト用のファイルを一行目でextendsし、
bodyという名前のブロックの中身を実際に指定しています。


これで、突然

  • タイトルを変えたくなった
  • 新しいjavascriptのライブラリを使いたくなった

となったときも、layout.htmlを編集すればおkで、
全テンプレートファイルを修正せずに済みます。

今回はここまで。

次回は

  • エントリ編集機能
  • エントリ削除機能
  • 全体の仕上げ

をして、ひとまずブログシステムを完成させます。

*1:分からない人はググってください

*2:分からない人は(ry