新着記事

Viewing posts for the category Django

実務経験0 入社一年目のエンジニアが任されたセキュリティの話

2021年春に入社しました、情報システム部の清瀬です。

TORICOの情報システム部では、セキュリティの向上を上半期の目標に掲げておりました。上半期も終わりに近づいてきたということで、新卒で入社した私がどんなセキュリティ向上のために携わってきた業務を記事にしようとおもいます。多くのシステムでは Django を使用しているためコード部分は Django 前提の話になります。

1. インフラレベルでの対応

AWS WAF の導入

  • SQLインジェクション対策
  • クロスサイトスクリプティング
  • その他、包括的な対策

TORICOでは一部の社内用アプリを除き、全てのサービスをAWSでデプロイしています。それら全てのサーバを悪意のあるリクエストから守るために、AWS WAF を導入しました。

AWS WAF は2019年に大幅アップデートされ、今までのものは WAF Classic と名称を変更しました。新しいWAFの最大のメリットは、るAWSによって作られたルールを簡単に導入できる点です。例えば AWSManagedRlesCommonRuleSet では、OWASPに記載された主要な脆弱性・リスク10個をカバーしてくれています。クロスサイトスクリプティングや悪意のあるボットを排除してくれるルールをボタン一つで導入できるため、包括的なセキュリティ対策をすることができます。もちろん、今までのようにオリジナルのルールを作ることができます。そのため、社内からのアクセスには一部のルールを緩和させたりなどもできるようになっています。

余談ですが、WAFの導入の検証を担当したのは、まだ私がインターン1週間くらいの時でした。検証のために簡易的な Django アプリを Ec2 にデプロイしてWAFの導入でクロスサイトスクリプティングやSQLインジェクションがきちんとブロックされるのかを検証しました。インターンなのにこんな重要そうな仕事をさせていただけるのかと驚いたのを今でも覚えています。

過去に使用していた IP アドレスのルーティングを解除

これはセキュリティとは少し違う話かもしれませんが、ドメインの整理などもしました。一部の未使用のドメインが、既に手放してある IP アドレスと結びついたまま放置されていました。もし IP アドレスが悪意のあるサイトに使われていた場合、最悪ユーザーを悪意のあるサイトに誘導してしまうということも起こり得ます。そうならないためにも、不要なドメインを整理しました。

2. アプリレベルでの対応

サービスのAdmin ・社内アプリ を社内からのみ許可する

  • 不正なアクセス対策
  • 個人情報の保護

こちらは Django の Middleware を使って対応しました。サブネットマスクの表現方法などが複数あるため IP を取り扱うのは面倒くさそうだなと思っていましたが、python には ipaddress という便利なモジュールが標準で搭載されています。このモジュールを使うことで簡単に実装することができました。このタスクは上記の WAF でも実装できますが、1つのALBで複数の社内アプリを制御していたためアプリレベルで対応することにしました。アプリレベルのため、各アプリに適した制限を導入することも簡単にできます。

ログインセッションキーを適切な設定にする

  • セッションハイジャック対策
  • CSRF対策

大事なセッションを管理してくれるクッキーにはセキュリティ対策は欠かせません。TORICOの全てのサイトで、その大切なCookieの設定が適切になるように対策をしました。具体的には HttpOnly Secure SameSite=Lax  の3つの設定です。これらの設定を簡単に説明すると、

HttpOnly: JavaScript からセッションにアクセスできないようにする。設定されていなかった場合、JS 経由で セッション情報が漏洩する危険がある。

Secure: https 通信でのみ セッションを送るようにする。設定されていなかった場合、暗号化されない Http 通信でセッションが送られてしまう危険がある。

SameSite: Strict, Lax, None の3つの設定がある。Lax の場合、別オリジンからの POST の時はセッションを送らない。None の場合、例えばECサイトの届け先を変更するフォームのある偽サイトからユーザーのセッションを使ってデータが送信されてしまう。

Django では 2系以上であれば settings で設定することができます。1系の場合は SameSite を設定することができません。クッキーセッションの注意点として、あまりにも厳しい設定にした場合、サービスがうまく動かなくなる可能性があります。特に SameSite は決済部分でエラーがでることがあるそうです。

個人情報出力時に追跡IDとログを残す

  • 個人情報漏洩時の漏洩元の究明

TORICOでは 商品の配送などの過程で、お客様の住所をCSVに出力することがあります。もしそういったデータが社外に流出した時、どのデータが流出したのかを追いやすいように追跡IDをそれぞれのファイルに追記するようにしました。ログにはリクエストの情報を全てを保存しているため、いつ・誰が・どの検索ワードで CSV を出力したのかを追うことができます。 Django には DB へのログ出力機能はないため、DBに出力する場合には自作する必要があります。

3. データベースの対応

重要なフィールドの暗号化

  • 個人情報の保護
  • データ漏洩時の影響を最小限にする

重要なフィールドを可逆暗号化することで、万が一情報漏洩した時でもその影響を最小限に留められるようにしました。可逆暗号は Django のオリジナルのフィールドを作ることで簡単に対応することができました。具体的な実装方法についてはこちらの記事で書きました。また、一部の古い社内アプリではアカウントのパスワードがハッシュ化されていなかったため、 bcrypt という方法で不可逆暗号化もしました。

ソルトとペッパーの役割の違いなどが曖昧だったため、とても勉強になったタスクだったなと思っています。

最後に

上半期に行った主要なセキュリティ対策を紹介していきました。セキュリティは深刻な障害の原因になるため、普段のコーディングから気をつけていきたいと思います。

Webの技術でレジ(POS) を開発・運用する

当社TORICOは、主にインターネット上での書籍の販売サービス、つまりECサイトを自社開発して展開しています。

その他に、自社でイベント会場(実店舗)を営業しており、そこではお客様に会場に来てもらうことでのサービスやグッズ販売を提供しています。

実店舗を営業するにあたり、レジ(POSシステム) が必要となります。TORICOでは、POSシステムもWeb技術で自社開発し運用しています。当社と同様に Web技術で POSを開発される方に向けて、私が対応したいくつかの経験を書き残します。

サーバサイドのシステム

当社の POS システムは、40万点以上の商品を扱う必要があり、そのDBとリアルタイムに連携する必要があるのでクラウド上でのサーバサイドアプリを開発運用しています。バーコードを元に商品DBからの商品検索を行ったり、売上をDBに記録するなどの機能を担っています。一般的にECサイト開発で書くようなコードとほぼ違いはありません。通常の開発と同様に、WebセキュリティとACIDを意識して書けば問題ありません。

当社では、Django + Django Rest Framework が鉄板構成のため、今回の POSシステムのサーバサイドも Django で開発しました。

クライアントサイドのシステム

クライアント側は、大きく分けてブラウザかモバイルアプリかを使うことになります。社内スタッフしか使わないという限定的なアプリであるため、SEOやアプリストアへのレギュレーション等は考える必要が無く、コンシューマ向けアプリを作るより開発は単純化できます。

Webブラウザかモバイルアプリか。どちらにも一長一短あり、展開したい構成によってどちらが適しているか変わってきます。展開規模が多ければ、両方を作ってみるのも良いですね。

結局、当社は最初は Webブラウザで作り実運用しています。現在はモバイルアプリでの研究開発も開始しています。
Webブラウザかモバイルアプリか、両者の長所や違いについては、この後のシステム構成案の個所で再度書きます。

レシート出力方法の選択

POSシステムである以上、会計レシートの出力が必須となります。

レシートプリンタは各社から市販されていますが、主に3通りの使い方が存在します。

1. Windows のプリンタドライバ・プリントキューで印刷する

通常のプリンタと同じように、Windows のプリンタドライバとプリントキュー経由でプリントする方法です。各社から市販されているレシートプリンタは、ほとんどに Windows 用のプリンタドライバも配布されており、これを使うことで OS やブラウザの印刷機能で幅広い表現のレシートをプリントできます。

特にブラウザを使う場合、HTMLでレシートがデザインできますので、高い表現力のレシートを素早く開発することができます。レシートカッターのついているプリンタにレシートカット指示を明示的に出すことはできませんが、大抵はプリント終了時に自動でカットされます。

通常の用紙設定と違い、設定には一工夫必要ですし、ソフトウェアによっては印刷の制限があったりします。例えば Chrome では最少文字サイズが決められていたり、マージンやヘッダーのレイアウトに限界があるため、ブラウザからのレシート出力をするのであれば Firefox がおすすめです。

パソコンの中でも Window と限定しているのは、Linux, Chromebook はプリンタドライバを提供しているメーカーが少ないため除外、Mac はレジに向いている端末が無いため POSレジの候補とならないためです。

2. プリントSDK を使う

主に iOS・Android 等のモバイルアプリOSでレシートを出力するには、プリントメーカーから公開されているSDK をアプリ内に組み込んで使うのが一般的で、これ以外の方法は難しいです。Windows用のSDKを公開しているメーカーも多いです。表現力は、HTMLより数段劣りますが、応答が早く、またレシートカッターやレジドロワーなどプリント以外の機能も柔軟に使えます。

3. シリアル通信で使う

プリンタメーカーによってはシリアル通信の仕様書が公開されていますので、SDKが無い場合やSDKを使いたく無い場合には、直接シリアル通信してプリントすることになります。例えば、スター精密のプリント仕様書はこのようになります: StarPRNT 仕様書 。通信仕様を把握しないといけないため大変ですが、Python, Ruby, NodeJS, PHP など比較的モダンな言語での開発はドライバは用意されていないと思いますので、実質シリアル通信コードを書くしか方法がありません。Bluetooth 対応のレシートプリンタは、Bluetoothでペアリングを確立した後、その TTYに向けてシリアル通信を行います。

実際に書いてみると、レシートへの出力パターンはそれほど多く無く、軽量言語からシリアル通信をするのはそれほど複雑コードにはなりません。つまりどころとしては、1行の中に左寄せと右寄せの文字を同時に出したり、表を出力する時に困るぐらいでしょうか。

購入レシートなので表っぽい形式での出力は必須となるため、ここが技術的にクリアできれば問題無いといえます。

それ以外

プリンタによっては、HTTPサーバが内蔵されており、そのHTTPサーバに向かって印刷内容をリクエストすると印刷できる機能を持ったものがあります。システムを ブラウザだけで完結させるこができ、使い勝手は良いのですが、通信インフラの設置制限などを受けます。

スター精密の機種は、WebPRNT というブランド名でこの機能が搭載されています。使いたかったのですが、当社にある「mC-Print3」は無線Lanインターフェイスがなく有線LANのみ搭載で、設置環境は有線LANのケーブリングは無理だったため今回は採用できませんでした。

また、プリンタによってはクラウド経由のプリントをサポートしているものもあるようです。

バーコードリーダーとの接続方法の選択

バーコードリーダーの接続方法は、大きく分けて2つあります。

「キーボード」として認識させるもの

大抵は、バーコードリーダは「キーボード」として扱います。

バーコードを読み取ると、そのコードがキーボードの打鍵としてOSに入ってきます。これはソフトを選ばず使えるため、使い勝手は良く、大抵この方法で問題ありません。

シリアルポート接続

バーコードリーダーによっては、シリアルポートとして接続させることもできます。Bluetoothで接続でき、市販のモバイルのレジアプリとの対応を謳っている製品にはこの機能が入っていると思います。キー入力に影響が無いためアプリの自由度は高まりますが、入力を受け付けるサービスを別途用意しなければいけないので、開発が少し手間です。

クライアントシステム構成の例

当社で運用している(検討している)具体的なハードウェア構成パターンを2つ書きます。

構成1. Windows + Firefox + OSのプリントキューでレシート印刷

当社で実際の販売点で使っている構成です。

Webブラウザを使ったレジアプリの場合、ハードウェアを Windows にすると、WIndows 特有の恩寵も多く受けられる非常に低コストで素早い開発が行えます。

利点

利点としてはこのような点があげられます。

  • 機能リリース(アップデート)が簡単。ブラウザをリロードするだけで良い。
  • プリンタドライバが豊富。間違い無く用意されている。
  • レシートを HTMLでレイアウトができるため、表現力が豊富で開発も早い。

特に、アップデートが早くて簡単なのは非常に高い優位があります。

フレームワーク

Webサイトのフロントエンドフレームワークとして、Vue, React, Angular 等のモダンなフレームワークを使うことで、レスポンスの良く体験に優れた POS アプリが簡単に作れます。CSSフレームワークも、Tailwind や Bootstrap等を用いることで美しいものが手軽に作れます。

弊社の鉄板構成は、Nuxt + TypeScript + Bootstrap です。

タッチ前提のUIにすると良い

開発中は、トラックパッドやマウスなどのポインティングデバイスでの利用を考えてしまいますが、実際のレジ現場ではタッチパネルを使ったほうが断然使いやすいので、タッチを前提としたUIにした方が良いです。ハードウェアも、Windowsタブレットを採用するのが良いでしょう。

Firefoxに限定している理由

ブラウザを Firefox に限定しているのは、Chrome の場合は最少フォント制限がありレシート印刷が難しいのと、Firefox はプリント時の余白設定も柔軟に行えるためです。

Firefoxでのプリント設定例

レシートプリント時、以下の設定を行うことで、専用システムのレシート出力に比べて遜色ないプリントができます。

  1. 余白を「なし」に設定する
  2. 倍率を「100%」に固定する
  3. ヘッダー・フッターはプリントしない
  4. プリントプレビューを表示しなくする

1〜3は、メニュー内の「プリント」をクリックすることで設定できます。

倍率を100% とし、横幅の制限は HTML上で作ります。例えば80mmプリンタを使うのであれば、レシートの内容は width: 70mm 程度にしたdiv の中に組んでいき、それをプリントさせるということです。

Firefoxでプリントプレビューを表示しない

Firefoxで プリントプレビューを表示しないようにするには、

about:config を開いて、print.always_print_silent を検索し、false となっているのを true にします。

ちなみにこの設定は、プラグインで簡単に設定できないかと思ったのですが、プラグインから about:config を操作するのは無理みたいで、実現できるものは存在しませんでした。

USB電源管理を無効化する

プリンタは USBで接続することになると思いますが、Windowsは使用方法によっては USBの仮想プリンタポート番号がいつの間にか変わってしまう場合があります。それを防ぐため、下記の記事を参考に、USBでの電源管理を無効化しておくとトラブルを防げます。

Windows10 (1803) USBの仮想プリンターポートを固定化する

デバイスマネージャの「ユニバーサル シリアル バス コントローラー」の中のUSB Root HubもしくはUSB ルート ハブ(USB 3.0)のプロパティを開き 「電源の管理」タブの「電力の節約のために、コンピューターでこのデバイスの電源をオフにできるようにする(A)」のチェックを外します。 

バーコードリーダ対応ライブラリ

Vue.js でキーボードとして認識されたバーコードリーダーからのバーコード入力を読み取る用途に

vue-barcode-scanner というライブラリが開発されており、これを使うと何の問題も無くバーコードリーダーとの連携ができます。

お客様ディスプレイ

レジスタッフだけではなく、お客様にも金額を確認してもらうため、お客様用のディスプレイの用意が必要になります。

ディスプレイ自体は、1万円弱でラズパイ用のディスプレイが多く出回っているのでそれを購入し、ブラウザから window.open() 等でお客様ディスプレイ用のウインドウを出します。

レジで打っている内容を、お客様ディスプレイに反映させる方法としては、ローカルストレージを使うのが一番手軽で良いと思います。

レジでバーコードをスキャンした時、その商品名や金額、合計金額をブラウザのローカルストレージに入れる。

お客様用ディスプレイのブラウザウィンドウで稼働しているJSコードは、ローカルストレージを500msとかでポーリングして監視し、変更があったらブラウザウィンドウに描画する。

プッシュ通知や PubSub, ウェブソケット等使うより素朴ですが、ローカルストレージをポーリングする方法は構成要素も少なく、安定して稼働できると考えています。

構成2. Android + Flutter + 専用プリンタドライバ

現在、研究開発段階でまだ実用化はできていません。

Sunmi というメーカーの POS 用品が美しく、今後は使っていきたいと思っているため、Flutter でアプリ開発を初めています。

レシート出力については、flutter_sunmi_printer というライブラリが pub で公開されており、これを使うと sunmi の内蔵プリンタへの出力が容易に行えます。

テーブル出力についても、このライブラリは 「Bootstrap のように 1行を12カラムに分けてレイアウトする」という方式を取っており、比較的容易に表組みを作れました。

実は、Windows POSシステムを作ると、開発は楽なのですがセットアップの工程が多かったりトラブルも多く、あまり安定稼働に向かないと考えています。その点、Android 主体のシステムは、アプリさえしっかり書いておけば安定動作はさせやすい(機材トラブルが少ない) と考えています。

Django の ORマッパーで生成されたSQLを実行前に置換し、JOIN時のインデックスを強制する

Django には、便利な ORマッパーが搭載されており、SQLを一切書かずともRDBの操作が行えます。

今回、SQL実行時に思ったようにインデックスが使用されず、パフォーマンスが出ない問題がありました。

これは、生SQLを実行した場合も同様にインデックスが使われなかったため、Django の問題ではなく、実行計画が最適に作成されなかったというSQL上の問題(というか仕様?)です。

SQL内にインデックスヒントを強引に書き込むことでインデックスを強制した所、正常なパフォーマンスとすることができたので、その方法を書きます。

MySQLの強制インデックス

今回、SQLを発行したページは Django の Admin 内で、子モデルの Admin ページから、親モデルを select_related して、リスト表示するというコードとなっていました。

@admin.register(models.Child)
class ChildAdmin(admin.ModelAdmin):
    ...
list_select_related = (
    'parent',
)

DB の種類は MySQL です。発行される SQL は以下のようになります。

SELECT *
FROM child
INNER JOIN parent
ON child.title_id = parent.title_id
ORDER BY child.id
LIMIT 100

※ 実際には SELECT 句の中は各フィールドが明示的に書き出されます

実際にこのSQLをSQLコンソールから実行してみると、50秒ほどかかりました。

(child は約100万レコード、parent は約40万レコードほどの分量です。)

予想より多くの時間がかかっており、テーブルフルスキャンが発生している印象です。

EXPLAIN してみると、possible_keys に title_id というキーを認識しているものの、実際にはキーが使用されると判断されない状況でした。

EXPLAIN 結果

※ 実際のテーブル名は child, parent ではないため、画像を加工しています。

ためしに、認識して使用されていないキー title_id の使用を強制し、SQLを発行してみます。

SELECT *
FROM child
  INNER JOIN parent
  FORCE INDEX (title_id)
  ON child.title_id = parent.title_id
ORDER BY child.id
LIMIT 100

FORCE INDEX (title_id) を追加しています。

すると、50ミリ秒 ほどでSQLを完了することができました。今回はインデックスが活用されています。

EXPLAIN で見ても、key が使われています。

解決へのアプローチ

インデックスが使われない問題は MySQL のサーバ側にあります。インデックスの再構築などを行うことで改善する可能性はあります。

ただし、今回は MySQL サーバへの変更などは行わず、コード上の変更により、 インデックス強制の SQL をなんとか発行する方針としました。

Django の SQL 生成のフック

Django は、搭載されている OR マッパーで SQL を組み上げます。その中には、テーブルジョインする際にインデックスを指定するオプションはありません

そこで、生成されたSQL を発行前にフックし、何らかの処理(置換)を行うことで、インデックス強制をさせることを考えました。

Python は、変数のほかクラス・関数などあらゆるものがファーストクラスオブジェクトであり、使用時に上書きや代入することができます。

特に、コア機能のメソッドを外部から上書きすることで動作を変更させる手法はパッチ、もしくはモンキーパッチと言われ、フレームワークやライブラリの主要機能を一時的に書き換えることも容易に行えます。

今回は、Djangoで生成されるSQL部分をパッチすることで、SQLの置換が行えそうです。

動作の安定性を失ったり(特にスレッド安全性など)、セキュリティ上の問題も生みやすく、動作を追うことが困難になるため、安直に行うべきものではありませんが、課題の解決やビジネス成長につながるのであれば選択肢として考えることはできます。Pythonは、速度など多くの犠牲のもと、柔軟な拡張性を獲得していると言えます。

SQL生成の箇所

Django のORマッパーを追っていくと、

  1. まず Model.objectsQuerySet を生成する
  2. QuerySetQuery を作成する
  3. Query が、 SQLCompier (django.db.models.sql.compiler.SQLCompiler) を作り、 as_sql メソッドを使ってSQL を組み上げる

という動作をします。

Model > QuerySet > Query > SQLCompiler

的な感じです。(記号はイメージです。has a 関係のようなものを表しているつもりですが、厳密ではありません)

SQLCompiler.as_sql を読んでみると、200行ほどからなるそこそこ分量のあるメソッドで、その中には JOIN 時にインデックス強制をできそうなフックポイントなどは無いため、簡単に処理は追加できません。

今回は、この as_sql を一旦どこかに退避し、新しくSQL置換機能をもった as_sql を勝手に作って SQLCompler の as_sql として置き換え、その勝手に作った as_sql の中で、もともとの as_sql を呼ぶことで、機能の拡張を行うものとします。

パッチのコード

具体的なコードとしては

def patch_sql_transformer():
    """
    queryset.query に sql_transformer というメソッドを付与すると、
    生成済み SQL をその sql_transformer で処理(置換など)して返す
    """
    from django.db.models.sql.compiler import SQLCompiler
    o_as_sql = SQLCompiler.as_sql

    def _decorate_as_sql(self, *args, **kwargs):
        sql, params = o_as_sql(self, *args, **kwargs)
        transformer = getattr(self.query, 'sql_transformer', None)
        if transformer:
            sql = transformer(sql)
        return sql, params

    SQLCompiler.as_sql = _decorate_as_sql

このようなコードとしました。このコードを、アプリの起動時のどこかで1回だけ実行することで、as_sql の機能が書き換わります。

機能としては、クエリセット内の Query のインスタンスに、 sql_transformer という関数がついていれば(つけていれば)、生成された SQL をその関数を通してから実行します。

今回、sql_transformer として実行したい関数は下記のものです。

def _transform_sql(sql):
    return sql.replace(
    'INNER JOIN `parent` ON',
        'INNER JOIN `parent` FORCE INDEX (title_id) ON')

近距離パワー型のアプローチですが、INNER JOIN 句を文字列置換して、強制的にインデックスを使わせます。

今回は Admin ページ内だけでこれを適用したかったため、このメソッドを ModelAdmin の get_queryset 内で作り、Query に仕込みます。

@admin.register(models.Child)
class ChildAdmin(admin.ModelAdmin):
   ...
    def get_queryset(self, request):
        qs = super().get_queryset(request)

        def _transform_sql(sql):
            return sql.replace(
                'INNER JOIN `parent` ON',
                'INNER JOIN `parent` FORCE INDEX (title_id) ON')
        qs.query.sql_transformer = _transform_sql
        return qs
 
    list_select_related = (
        'parent',
    )
...

これにより、インデックスが強制指定で使われるようになり、速度を改善させることができました。

なぜ Query にメソッドを作るのか?

SQL の生成時は

Model > QuerySet > Query > SQLCompiler

とモデルが関係していますが、今回、なぜ半端な位置の Querysql_transformer を生やす、というデザインにしたかというと、それ以外に適した場所がなかったからです。

クエリセットを生成中の、QuerySet や、 SQLCompiler は、クエリセットをチェーンする時

例えば

qs = MyModel.objects.filter(active=True)
qs = qs.filter(item_type=xxx)
qs = qs.order_by('-id')
qs = qs.limit(100)

のようなコードの場合 (実際、Django の ModelAdmin の中では上記相当のコードが発行されています)、毎回 queryset インスタンスが作り直されます。

なので、queryset インスタンスになにかフック用の関数を付与したとしても、SQLCompiler の SQL 構築まではインスタンスが無くなり、メソッドも失われてしまいます。同様に、SQLCompiler になにか関数を作っても、クエリセットが作り直される時に消えます。

では、その中間にある Query はというと、QuerySet のコンストラクト引数として常に引き渡され、場合によっては deepcopy され、インスタンスのプロパティや変数などは大元の QuerySet がすべて消えるまで生存しており、かつ SQLCompiler の第一引数でもあるためアクセスが容易ですので、今回は Query のインスタンスを用いて関数の引き回しをすることにしました。

Django に Celery タスクキューを導入し、遅い処理を利用者に体感させないようにする

Django でウェブアプリを作る際、遅い処理をタスクキューにするには、celery が便利です。今回、社内勉強会で Django + celery のチュートリアルを行ったので、celery で簡単なタスクを動かすまでを書いておきます。

内容としては Celery ドキュメントの First steps with Django をなぞっています。

環境

  • MacOS
  • Python 3.6.5
  • Django 2.1
  • Celery 4.2.1
  • Redis

Redisは、キューのブローカーとして使います。Redis以外にも、RabbitMQ やAmazon SQS が使えます。

Redis サーバの起動方法は書いていませんので、適宜起動してください。

プロジェクトフォルダの作成

$ mkdir celery_handson
$ cd celery_handson

venv (仮想環境) の作成

$ python3.6 -m venv venv
$ . venv/bin/activate

ライブラリのインストール

$ pip install django
$ pip install celery django-celery-results redis django-redis
$ pip install ipython

django-admin.py を認識させるため、仮想環境に入り直しておきます。

$ deactivate
$ . venv/bin/activate

Djangoプロジェクトの作成

(今、celery_handson ディレクトリにいますが、さらにその中に celery_handson という名前の Django プロジェクトを作ります)

$ django-admin.py startproject celery_handson
$ cd celery_handson

Django_celery_results の導入

settings.py を編集し、INSTALLED_APPS に django_celery_results を追加します。

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_celery_results', # ←追加
]

DBの作成

$ ./manage.py migrate

これで、Djangoの初期設定は完了しました。

試しに、

$ ./manage.py runserver

を起動し、http://127.0.0.1:8000/ にアクセスしてみると、テストページが表示されます。

Celery 設定の追加

settings.py に追加します。

CELERY_BROKER_URL = "redis://<Redisサーバのホスト>:6379/1"
CELERY_RESULT_BACKEND = "django-db"

Celery ファイルの作成

celery_handson/celery.py を作成

urls.py の並びに、celery.py を作ります。

# celery_handson/celery_handson/celery.py
import os
from celery import Celery

# set the default Django settings module for the 'celery' program.
settings = os.getenv(
"DJANGO_SETTINGS_MODULE", "celery_handson.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings)

app = Celery('celery_handson')

# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')

# Load task modules from all registered Django app configs.
app.autodiscover_tasks(['celery_handson'])

公式ドキュメントにあるファイルほぼそのままです。

タスクスクリプトの作成

urls.py, celery.py の並びに、tasks ディレクトリを作り、その中に __init__.py を作ります。

# celery_handson/celery_handson/tasks/__init__.py
from ..celery import app


@app.task()
def add_numbers(a, b):
print('Request: {}'.format(a + b))
return a + b

celery ワーカーの起動

./manage.py があるディレクトリで行います。

celery -A celery_handson worker --concurrency=1

--concurrency は並列性のオプションで、省略しても問題ありませんが開発中は1が扱いやすいように思います。

ターミナル内で実行し続けますので、そのターミナルはそのままにして、次の「タスクの実行」からは新しいターミナルを起動してください。

(ちなみに celery 実行バイナリは、venv/bin/celery にいます。)

(iTerm2 を使っている場合、ターミナルに画像が表示されます)

タスクの実行

同期処理

$ ./manage.py shell
>>> from celery_handson.tasks import add_numbers
>>> add_numbers(3, 4)
Request: 7
7

普通に同プロセス内で add_numbers をコールしただけです。これは celery は関係ありません。

非同期処理

>>> add_numbers.delay(5, 6)
<AsyncResult: deeddee2-74a6-48ec-9f22-987417135592>

@app.task() デコレータがついた関数は Celery のタスクとして登録され、.delay メソッドが使えるようになっています。.delay をコールすることで、非同期実行のタスクキューとしてブローカーに登録され、ワーカーはそれを拾って処理を行います。

.delay メソッドの戻り値である AsyncResult は、result プロパティの中に結果が入っているかもしれません。

また、django_celery_results を使っていれば処理結果は DB (モデル) に記録され、task_id の値を元に取得することができます。

>>> tr = TaskResult.objects.get(task_id='2781413b-282e-4de8-b134-b338b592ad02')
>>> tr.status
'SUCCESS'
>>> tr.result
'11'

ビュー内で行う重い処理や、マネジメントコマンドから実行するタスクで並列したいものは、このタスクの delay メソッドを使うことで簡単に非同期化でき、使用者のストレスを下げることができます。

PyCharm の設定

タスクの開発は、他のスクリプトと同様にIDEでブレイクポイントを設定すると効率的に開発できます。

Django の環境設定

一番上の celery_handson ディレクトリを PyCharm で開きます。

Project Interpreter

⌘+, で設定を開き、検索窓に interpreter と入力。

Project Interpreter を、先程作った venv 内のPython を指定します。最近の PyCharm は自動認識します。

Django Support

検索窓に django と入力し、Languages & Frameworks ➝ Django の設定をします。Enable Django Support にチェックを入れ、Settings に celery_handson/settings.py を指定。

Project Structure

検索窓に structure と入力し、./manage.py が入っている celery_handson ディレクトリを Sources に追加しておきます。

Run/Debug Configurations の登録

エディタ右上の、Edit Configurations... を選択し、+ ボタンから Django Server を選択 して設定を登録します。

Enironment variables (環境変数) に、DJANGO_SETTINGS_MODULE celery_handson.settings を登録しておきます。(無くても動くかもしれません。PyCharm の他の設定によります。設定しとくと無難です)

この設定を実行すると、Django テストサーバが起動します。

Celery用の設定

Run/Debug Configurations の + をクリック ➝ Python で、

Script path に venv/bin/celery、parameters に -A celery_handson worker --concurrency=1 を、念の為 Environment variables: に DJANGO_SETTINGS_MODULE celery_handson.settings を登録しておきます。

この環境をデバッグ起動すれば、ワーカープロセス内でブレイクポイントが使えるようになり、開発がしやすくなります。

Celery ワーカーの状態取得

from celery.task import control
inspect = control.inspect()
inspect.stats()  # ステータスを取得
inspect.ping()  # 疎通確認
inspect.active()  # 現在実行中のタスク

Supervisor の設定サンプル

[program:celery-handson]
autostart = true
autorestart = true
user = ubuntu
environment = DJANGO_SETTINGS_MODULE=celery_handson.settings
command = /var/django/celery_handson/venv/bin/celery -A celery_handson worker --concurrency=8
directory = /var/django/celery_handson/celery_handson
stopasgroup = true

; logs
stdout_logfile = /var/log/supervisor/celery_handson.log
stdout_logfile_maxbytes = 1MB
stdout_logfile_backups = 5
stdout_capture_maxbytes = 1MB
redirect_stderr = true


; プロセス再起動:
; sudo supervisorctl restart celery-handson


メモ: django_celery_beat でDBで設定したタスクを実行させるには

command = /var/django/celery_handson/venv/bin/celery -A celery_handson beat --scheduler=django_celery_beat.schedulers:DatabaseScheduler

Django の CRUD ジェネリックビュー (ListView, DetailView, CreateView, UpdateView, DeleteView) の簡単な使い方

Django は、Python プログラミング言語で動作するウェブアプリケーションフレームワークです。

この記事は、Django の使い方を説明するものです。

Djangoの解説: The Web framework for perfectionists with deadlines | Django


Django に最初から用意されているビュー(ジェネリックビュー) を使えば、

  • オブジェクトのリスト表示
  • オブジェクトの作成
  • オブジェクトの詳細表示
  • オブジェクトの更新
  • オブジェクトの削除

のWebアプリが簡単に作成できます。

(オブジェクト=DBのレコード=モデルインスタンス=アクティブレコードオブジェクト という意味です)

Create, Read, Update, Delete をまとめて CRUD と呼んだりしますが、Django では CRUD に対応した汎用的な基底ビューコントローラが用意されているため、それを継承してビューを作ることで、安全で、読みやすいコードを書けます。

それぞれ、以下のビューコントローラに対応しています。

Create … CreateView
Read (リスト表示) … ListView
Read (詳細表示) … DetailView
Update … UpdateView
Delete … DeleteView

django.views.generic の中にいます。

Django では、これらのさらに基底となる TemplateView, FormView もありますが、やっている仕事として「モデルインスタンスを1つ作成/更新」「モデルインスタンス1つを詳しく表示」など、今回の CRUD の基底ビューに含まれる場合は、TemplateView, FormView を使うよりこれらのジェネリックビューを継承したほうがコードを読む際に書き手の意図を理解しやすいため、これらのクラスを使うべきです。

今回は、シンプルなテキストメモを作成・更新するWeb アプリのサンプルを作ってみました。
GitHubにコードを上げてありますので、同時にご参照ください。

リポジトリ

https://github.com/ytyng/django-crud-generic-view-tutorial

核心は、views.py なのでそれを見るとよくわかります。お急ぎの方はこれだけ読んでみてください。

https://github.com/ytyng/django-crud-generic-view-tutorial/blob/master/memo/memo/views.py

環境

Django 1.11, Python 3.6

ディレクトリ構造

memo/
+ manage.py
+ db.sqlite3
+ memo /
+ settings.py (Django設定(自動生成))
+ wsgi.py (WSGIランナー(自動生成))
+ urls.py (URLマップ(自動生成))
+ models.py (Memoモデルの定義)
+ views.py (ビューコントローラ)
+ forms.py (HTMLフォームクラス)
+ migrations (マイグレーションスクリプト(自動生成))
+ templates (HTMLテンプレート)
+ memo
+ base.html
+ memo_confirm_delete.html (削除確認用テンプレート)
+ memo_detail.html (詳細表示用テンプレート)
+ memo_form.html (作成、更新フォーム用テンプレート)
+ base.html (基底テンプレート)
+ memo_list.html (リスト表示用テンプレート)

コード解説

解説はコード中のコメントに書きました

models.py

from django.db import models


class Memo(models.Model):
"""
メモモデル。実質、件名と本文のみ。
"""
subject = models.CharField(
verbose_name='件名',
max_length=100,
default='',
blank=True
)

body = models.TextField(
verbose_name='本文',
default='',
blank=True
)

# created: auto_now_add を指定すると、作成日時を自動保存する
created = models.DateTimeField(
auto_now_add=True
)

# updated: auto_now を指定すると、更新日時を自動保存する
updated = models.DateTimeField(
auto_now=True
)

def __str__(self):
return self.subject

forms.py

from django import forms

from .models import Memo


class MemoForm(forms.ModelForm):
"""
Memo モデルの作成、更新に使われる Django フォーム。
ModelForm を継承して作れば、HTMLで表示したいフィールドを
指定するだけで HTML フォームを作ってくれる。
"""

class Meta:
model = Memo
fields = ['subject', 'body']

views.py

今回の核心です。Django の ListView, DetailView, CreateView, UpdateView, DeleteView をそれぞれ継承し、CRUDのビューコントローラを作成しています。

フォームクラスは、先程の MemoForm を使います。

それぞれのビューで書かなければならない内容は少なく、実務的な処理は Django に任せることで、エンジニアは UI の開発に集中できます。

from django.contrib import messages
from django.urls import reverse_lazy
from django.views.generic import \
ListView, DetailView, CreateView, UpdateView, DeleteView

from .models import Memo
from .forms import MemoForm


class MemoListView(ListView):
"""
メモを一覧表示
テンプレートは、何も指定しないと モデル名_list.html が使われる
ListView は、パジネーションもやってくれる
"""
model = Memo
paginate_by = 10 # 1ページに表示する件数


class MemoDetailView(DetailView):
"""
1つのメモを詳細表示
テンプレートは、何も指定しないと モデル名_detail.html が使われる
"""
model = Memo


class MemoCreateView(CreateView):
"""
メモ 新規作成
完了ページを作成し、success_url で指定して表示してもいいが、
django.contrib.messages の機能で、メッセージを保存して
リストビューなんかに戻した時に表示するのも簡潔で良い。
"""
model = Memo
form_class = MemoForm
success_url = reverse_lazy('memo_list')

def form_valid(self, form):
result = super().form_valid(form)
messages.success(
self.request, '「{}」を作成しました'.format(form.instance))
return result


class MemoUpdateView(UpdateView):
"""
メモ 更新
"""
model = Memo
form_class = MemoForm

success_url = reverse_lazy('memo_list')

def form_valid(self, form):
result = super().form_valid(form)
messages.success(
self.request, '「{}」を更新しました'.format(form.instance))
return result


class MemoDeleteView(DeleteView):
"""
メモ 削除
デフォルトでは、get でリクエストすると確認ページ、
post でリクエストすると削除を実行する、という動作。
実際は、レコードを削除するのではなく有効フラグを消す(いわゆる論理削除)
のケースが多いと思うので、そんな時はdeleteをオーバーライドしてその中で処理を書く。
"""
model = Memo
form_class = MemoForm

success_url = reverse_lazy('memo_list')

def delete(self, request, *args, **kwargs):
result = super().delete(request, *args, **kwargs)
messages.success(
self.request, '「{}」を削除しました'.format(self.object))
return result

urls.py

from django.conf.urls import url
from django.contrib import admin

from . import views

urlpatterns = [
url(r'^admin/', admin.site.urls),

url(r'^$',
views.MemoListView.as_view(),
name='memo_list'),

url(r'^detail/(?P<pk>\d+)/$',
views.MemoDetailView.as_view(),
name='memo_detail'),

url(r'^create/$',
views.MemoCreateView.as_view(),
name='memo_create'),

url(r'^update/(?P<pk>\d+)/$',
views.MemoUpdateView.as_view(),
name='memo_update'),

url(r'^delete/(?P<pk>\d+)/$',
views.MemoDeleteView.as_view(),
name='memo_delete'),
]

テンプレート

Bootstrap4でさっくり作ってあります。

base.html

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{# Bootstrap4 で。#}
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb"
crossorigin="anonymous">
<title>{% block meta_title %}{% endblock %}</title>
<body>

{% if messages %}
{# Django のメッセージに記録している内容があればここで表示 #}
<div class="container">
<div class="row">
<div class="col-12">
<div class="messages mt-3">
{% for message in messages %}
<div class="alert alert-dismissable alert-{{ message.tags }}" data-alert="alert">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ message }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}

<div class="container">
<div class="row">
<div class="col-12">
<h1 class="mt-5">{% block page_title %}Memo{% endblock %}</h1>
</div>
</div>
</div>
{% block content %}
{% endblock %}
{% block foot_scripts %}
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js"
integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh"
crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js"
integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ"
crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>

memo_list.html

シンプルなメモ一覧テンプレート。簡易的なパジネーションつき

{% extends 'memo/base.html' %}
{% block content %}

<div class="container">
<div class="row">
<div class="col-12">
<div class="card card-default">
<ul class="list-group list-group-flush">
{% for o in object_list %}
<li class="list-group-item">
<a href="{% url 'memo_detail' o.pk %}">{{ o.subject }}</a>
</li>
{% empty %}
<li class="list-group-item">
メモはありません
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-12">
<div class="mt-3 text-center">
{% if page_obj.has_previous %}
<a class="btn btn-secondary" href="/?page={{ page_obj.previous_page_number }}">前へ</a>
{% endif %}
<span>{{ page_obj.number }}/{{ paginator.num_pages }}ページ</span>
{% if page_obj.has_next %}
<a class="btn btn-secondary" href="/?page={{ page_obj.next_page_number }}">次へ</a>
{% endif %}
</div>
</div>
</div>
</div>

<div class="container">
<div class="row">
<div class="col-12 mt-3">
<a href="{% url 'memo_create' %}" class="btn btn-primary">新規作成</a>
</div>
</div>
</div>
{% endblock %}

memo_detail.html

詳細表示。MemoDetailView で使われる。

{% extends 'memo/base.html' %}
{% block meta_title %}{{ object.subject }}{% endblock %}
{% block page_title %}{{ object.subject }}{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card card-default">
<div class="card-body">
{{ object.body|linebreaksbr }}
</div>
<div class="card-footer">
<a class="btn btn-primary" href="{% url 'memo_update' object.pk %}">修正</a>
<a class="btn btn-danger" href="{% url 'memo_delete' object.pk %}">削除</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

memo_form.html

新規作成/更新フォーム。 MemoCreateView, MemoUpdateView で使われる。
HTMLフォームは、MemoForm のインスタンスを bootstrap フィルタにかければ自動的にいい感じで作ってくれる。バリデーション〜エラー対応もやってくれる。

{% extends 'memo/base.html' %}
{% load bootstrap %}
{% block meta_title %}{% if object.pk %}{{ object.subject }}{% else %}新規作成{% endif %}{% endblock %}
{% block page_title %}{% if object.pk %}{{ object.subject }}{% else %}新規作成{% endif %}{% endblock %}
{% block content %}
<form method="post">
<div class="container">
<div class="row">
<div class="col-12">
{{ form|bootstrap }}
</div>
</div>
<div class="row">
<div class="col-12">
<button class="btn btn-primary">保存</button>
</div>
</div>
</div>
{% csrf_token %}
</form>
{% endblock %}

memo_confirm_delete.html

削除確認フォーム

{% extends 'memo/base.html' %}
{% load bootstrap %}
{% block meta_title %}削除確認{% endblock %}
{% block page_title %}削除確認{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<p>{{ object }} を削除していいですか?</p>
<form method="post">
<button class="btn btn-danger">削除する</button>
{% csrf_token %}
</form>
</div>
</div>
</div>
{% endblock %}

PyCharm + Docker で Django 開発環境を構築する

PyCharm 2016 の Docker サポートの使い勝手が良いです。

Docker で開発環境を作るのは初めてだったのですが、

  • ローカル ( mac ) は Docker Toolbox を使って開発環境を作る。PyCharm のデバッガでステップ実行できるようにする。
  • ソースコード、およびログディレクトリは Docker イメージの中に入れず、ホストのディレクトリをマウントして使う
  • 公開サーバを見越して uwsgi, nginx の設定も作っておく


という所までできたので、手順を書きます。

コード置いてあります。 https://github.com/ytyng/docker-django-skeleton

こちらを参考にしました。https://github.com/dockerfiles/django-uwsgi-nginx

必要アプリのインストール

インストーラに従ってインストールしておきます。

プロジェクトディレクトリを作成し、PyCharm で開く

$ mkdir docker-django-skeleton

$ open -a PyCharm docker-django-skeleton

PyCharm用の Bash プラグインのインストール

メニューバーの PyCharm → Preferences → 検索: Plugins → Plugins の検索で「BashSupport」を検索 → 該当したものをインストールしておきます。

PyCharmの再起動を求められるので、案内通り再起動します。

Docker 用のファイルを作る

プロジェクトルートに Dockerfile そのまま置くと煩雑になってしまうので、docker ディレクトリを作って関連ファイルを入れます。

docker/Dockerfile

FROM ubuntu:16.04

MAINTAINER ytyng

RUN apt-get update && apt-get install -y \
    python3 \
    python3-pip \
    python3-dev \
    python3-setuptools \
    libjpeg-dev \
    nginx \
    supervisor \
  && rm -rf /var/lib/apt/lists/*

# ipython, gnureadline が必要なら
# lib32ncurses5-dev

RUN ln -s /usr/lib/x86_64-linux-gnu/libjpeg.so /usr/lib/libjpeg.so

# RUN easy_install3 pip  # aptで入れた方が安定

COPY requirements/base.txt /tmp/requirements/base.txt
RUN pip3 install -r /tmp/requirements/base.txt

# setup all the configfiles
RUN echo "daemon off;" >> /etc/nginx/nginx.conf
COPY nginx-app.conf /etc/nginx/sites-available/default
COPY supervisor-app.conf /etc/supervisor/conf.d/

RUN mkdir -p /etc/nginx/certs
# COPY certs /etc/nginx/certs/

RUN mkdir -p /var/run/django/

# set locale
RUN locale-gen en_US.UTF-8
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

EXPOSE 80
#EXPOSE 443

↑ libjpeg などインストールしてるのは Pillow 用

docker/requirements/base.txt

Django==1.10.1
pytz
uwsgi
# MySQL使う場合
PyMySQL

↑ 今回のサンプルは、DBは SQLite3 なので PyMySQL 不要ですがサンプルとして書いてます。



docker/nginx-app.conf

server {
    listen 80;
    # listen 443 ssl;

    server_name docker-django-skeleton.example.com;

    charset utf-8;

    # ssl_certificate /etc/nginx/certs/xxx.crt;
    # ssl_certificate_key /etc/nginx/certs/xxx.key;

    location /static/ {
        alias /var/django/docker-django-skeleton/staticfiles/;
        break;
    }

    location / {
        include uwsgi_params;
        uwsgi_pass 127.0.0.1:3031;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker/supervisor-app.conf

[program:app-uwsgi]
command = /usr/local/bin/uwsgi --ini /var/django/docker-django-skeleton/conf/uwsgi.ini

[program:nginx-app]
command = /usr/sbin/nginx

Docker 用の設定ファイルの作成

環境変数など設定する Bash スクリプトを用意しておきます。
mac で実行した場合と公開サーバで起動した場合に読み込まれる設定を変えるようにしています。

docker/_settings.sh

#!/usr/bin/env bash

APP_NAME=docker-django-skeleton
DJANGO_APP_NAME=docker_django_skeleton

PROJECT_DIR=$(cd $(dirname ${BASH_SOURCE:-$0})/..;pwd)

if [ $(which docker-machine) ]; then
    # mac
    eval "$(docker-machine env default)"
    DOCKER_COMMAND=docker
    DOCKER_MACHINE_COMMAND=docker-machine
    LOG_DIR=/tmp/log/${APP_NAME}
    export UWSGI_PROCESSES=1
    export UWSGI_THREADS=1
    export DJANGO_SETTINGS_MODULE=${DJANGO_APP_NAME}.settings.local
else
    # ubuntu
    DOCKER_COMMAND="sudo docker"
    LOG_DIR=/var/log/${APP_NAME}
    OPTIONAL_PARAMS="--link mysql:mysql"
    RUN_COMMAND="supervisord -n"
    export UWSGI_PROCESSES=2
    export UWSGI_THREADS=2
    export DJANGO_SETTINGS_MODULE=${DJANGO_APP_NAME}.settings.production
fi


Dockerイメージ作成用スクリプト

docker/docker-01-build.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

${DOCKER_COMMAND} build -t ${APP_NAME} .

docker/docker-02-run.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

mkdir -p -m 777 ${LOG_DIR}

${DOCKER_COMMAND} run -d -P -p 80:80 -p 443:443 \
  -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
  -v ${LOG_DIR}:/var/log/django \
  -v /etc/localtime:/etc/localtime:ro \
  -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
  -e UWSGI_PROCESSES=${UWSGI_PROCESSES} \
  -e UWSGI_THREADS=${UWSGI_THREADS} \
  ${OPTIONAL_PARAMS} \
  --name ${APP_NAME} ${APP_NAME} ${RUN_COMMAND}

if [ "${DOCKER_MACHINE_COMMAND}" ]; then
    docker-machine ip default
fi

${DOCKER_COMMAND} ps

docker/docker-03-bash.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

EXISTS=$(${DOCKER_COMMAND} ps --quiet --filter name=${APP_NAME})

if [ "${EXISTS}" ]; then
    ${DOCKER_COMMAND} exec -it ${APP_NAME} /bin/bash $@
else
    ${DOCKER_COMMAND} run --rm -it \
    -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
    -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
    --name ${APP_NAME} ${APP_NAME} /bin/bash $@
fi

docker/docker-09-rm.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

${DOCKER_COMMAND} rm --force ${APP_NAME}

docker/manage.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

EXISTS=$(${DOCKER_COMMAND} ps --quiet --filter name=${APP_NAME})

if [ "${EXISTS}" ]; then
    ${DOCKER_COMMAND} exec -it ${APP_NAME} \
    /bin/bash -c "cd /var/django/${APP_NAME}/${APP_NAME}/; ./manage.py $*"

else
    ${DOCKER_COMMAND} run --rm -it \
    -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
    -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
    --name ${APP_NAME} ${APP_NAME} /bin/bash -c \
    "cd /var/django/${APP_NAME}/${APP_NAME}/; ./manage.py $*"
fi


*.sh には、実行パーミッションを付与しておきます。

$ chmod +x docker-*.sh manage.sh

docker-django-skeleton
└── docker
    ├── Dockerfile
    ├── _settings.sh
    ├── docker-01-build.sh
    ├── docker-02-run.sh
    ├── docker-03-bash.sh
    ├── docker-09-rm.sh
    ├── manage.sh
    ├── nginx-app.conf
    ├── requirements
    │   └── base.txt
    └── supervisor-app.conf


Docker イメージの作成

先ほど作成した、docker-01-build.sh を PyCharm 上で右クリック → Run 'docker-01-build.sh'


Dockerイメージができます。

Django プロジェクトディレクトリの作成

既に他の件で Django が mac にインストールされている場合は、それを使って `django-admin.py startproject` すれば良いと思いますが、今回は先ほど Docker イメージにインストールした Django を使って Django プロジェクトを作成します。

プロジェクトディレクトリをまるごと Docker にマウントし、Docker内で startproject します。

$ docker run -it -v `pwd`:/var/django/docker-django-skeleton docker-django-skeleton /bin/bash

※ pwd は、docker-django-skeleton ディレクトリ。docker ディレクトリの親ディレクトリ

$ docker run -it -v `pwd`:/var/django/docker-django-skeleton docker-django-skeleton /bin/bash
root@8d2ba7a54287:/# cd /var/django/docker-django-skeleton/
root@8d2ba7a54287:/var/django/docker-django-skeleton# django-admin.py startproject docker_django_skeleton
root@8d2ba7a54287:/var/django/docker-django-skeleton# mv docker_django_skeleton docker-django-skeleton
root@8d2ba7a54287:/var/django/docker-django-skeleton#


好みの問題ですが、Django プロジェクトのディレクトリ名は アンダーバーではなく ハイフンに変更しています。

PyCharm を見てみると、docker-django-skeleton ディレクトリが作成されているのが確認できます。

settings を環境ごとに分ける

Django でよくやるプラクティスで、settings を ローカル環境と公開環境で分けるというものがあります。

docker_django_skeleton ディレクトリ内で、

1. settings ディレクトリを作成
2. settings.py は、 settings/base.py に改名
3. settings/local.py settings/production.py を作成。


settings/local.py, settings/production.py の内容は以下のようになります。

settings/local.py

from .base import *  # NOQA


内容はありませんが、今回はこれで良しとします。

manage.py のインタプリタ (シェバン shebang ) を python3 にしておく


作成された manage.py を見てみると、1行目が

manage.py

#!/usr/bin/env python

となっていますが、Docker イメージ内には Python2 が残っているため不具合が起こることがあります。

#!/usr/bin/env python3

に変更しておきます。

PyCharm のインタプリタで Docker の Python を指定する

PyCharm → Preferences → 検索: interpreter

Project Interpreter が出てきます。

ここで、右上の「...」ボタンをクリックし、Add Remote を選択。

Configure Remote Python Interpreter になるので、
「Docker」を選択、Image name は docker-django-skeleton:latest を選択してください。

それと、Python interpreter path: は python となっていますが、python3 に変更します。

すると、Docker 内の Python 環境を認識します。すごい!

Djangoサポートを有効にする

Preferences で、検索: django

Language & Frameworks の Django が出て来るので選択し、Enable Django Support にチェック。

Django project root: manage.py のある、docker-django-skeleton
Settings: docker_django_skeleton/settings/local.py

OKをクリック

Project Structure を設定する

Preferences で、検索: structure

manage.py のある、docker-django-skeleton ディレクトリを選択し、Source をクリック → OK

migrate

次は、./manage.py migrate をします。
docker コンテナ内なので、./manage.py を実行するのも少し面倒ですが、docker ディレクトリの中に manage.sh を作ってあるのでこれを使います。

$ cd docker
$ ./manage.sh

Type 'manage.py help <subcommand>' for help on a specific subcommand.

Available subcommands:

[auth]
    changepassword
    createsuperuser

[django]
    check
    compilemessages
...

$ ./manage.sh migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
...

Docker の Django環境を使ってマイグレーションしてくれました。

テストサーバの起動

PyCharm の右上、Run/Debug の選択をクリックし、Edit Configurations...

Run/Debug Configurations ダイアログで、左上の + をクリック

Django server を選択

Host: には、0.0.0.0 を入力(重要)
Name: は適当に (例: runserver )
→ OK

出来た runserver 実行環境が選択されている状態で、虫(デバッグ) ボタンを押下

System check identified no issues (0 silenced).
September 26, 2016 - 09:54:15
Django version 1.10.1, using settings 'docker_django_skeleton.settings.local'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.

となりますので、0.0.0.0:8000 をクリックするとブラウザが起動し、Docker マシーンにリダイレクトされてページが表示されます。
デバッガも有効な状態ですので、ブレイクポイントが使えます。すごい!

公開環境で起動する

uwsgi.ini

[uwsgi]

app = $(APP_NAME)
django_app = $(DJANGO_APP_NAME)

base = /var/django/%(app)/%(app)/
pythonpath = %(base)
chdir = %(base)

socket = 127.0.0.1:3031

module = %(django_app).wsgi:application

processes = $(UWSGI_PROCESSES)
threads = $(UWSGI_THREADS)

master = true

pidfile = /var/run/django/%(app).pid

touch-reload = %(base)/%(django_app)/wsgi.py
lazy-apps = true
thunder-lock = true

buffer-size = 32768

logto = /var/log/django/%(app)/%n.log

uwsgi の設定ファイルはこんな感じですかね。

ソースコードを公開環境のサーバに持っていって、

$ ./docker-01-build.sh

でコンテナを作り (ローカルで作ったのを Docker hub に push -> 本番で Pull でもいいですが)、

$ docker-02-run.sh

で起動すると、公開環境 (というか docker-machine がインストールされてない環境 ) では本番用設定で nginx → uwsgi → Django で起動します。

Docker コンテナを削除する時は

$ docker-09-rm.sh

で消えます。

Djangoプロジェクト間を OAuth2 連携する

ローカル環境に Django プロジェクトを2つ作り、OAuth2で(ダミーの)プロフィール情報を取得するまで書きます。

2つの Django プロジェクトを作ります。test_provider, test_consumer です。

OAuth2の認証フローは、サーバ間通信のため「Authorization Code」形式で行います。

OAuth2についての情報は IPAのページ などにあります

ソースコードを github に上げました。

Django プロジェクト 概要

test_provider

起動ポート 8000
ユーザーのIDとハッシュ化パスワードを保持します。
OAuth2 プロバイダを提供します。
ユーザーに test_provider への情報提供の認可を問います。
django-oauth-toolkit を使います

test_consumer

起動ポート 8001
ユーザーが最初にアクセスする Webページ
ユーザーに test_provider への認可ページへ誘導します。
ユーザー認可後は、このプログラムが test_provider へリクエストを行い、情報をもらいます。
django-allauth を使います。

※なお、django-allauth と同じくらい使いやすいのが python-social-auth です。Django のライブラリも含んでいます。

django-social-auth というライブラリも見かけると思いますが、Python3に対応していないのでやめておきましょう。

OAuth2プロバイダの開発

django-oauth-toolkit の素晴らしいドキュメントがここにあります。
「Make a Provider in a Minute」という挑発的なタイトルですが、
認証モデルにこだわりがなければほんとにすぐ作れます。
がんばっても1分では作れないと思いますが…。

Django プロジェクトの作成

$ mkdir test-oauth
$ cd test-oauth

$ django-admin.py startproject test_provider
$ pip install django-oauth-toolkit django-cors-headers

$ cd test_provider

test_provider/settings.py の編集

INSTALLED_APPS に追加

    'oauth2_provider',
    'corsheaders',

MIDDLEWARE_CLASSES に追加

'corsheaders.middleware.CorsMiddleware',

末尾に追加

CORS_ORIGIN_ALLOW_ALL = True

test_provider/urls.py の編集

from django.conf.urls import url, include
from django.contrib import admin
from .views import profile_view

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
    url(r'^api/profile/$', profile_view, name='profile'),
    url(r'^accounts/login/', 'django.contrib.auth.views.login', name='login',
        kwargs={'template_name': 'admin/login.html'}),
]

test_provider/views.py の作成

from django.http import JsonResponse
from oauth2_provider.views.generic import ProtectedResourceView

class ProfileView(ProtectedResourceView):
    def get(self, request, **kwargs):
        user = request.resource_owner

        return JsonResponse({
            'user_id': user.id,
            'email': user.email,
            'date_joined': user.date_joined,
            'secret_message': 'The quick brown fox',
        })

profile_view = ProfileView.as_view()

DB に反映

$ ./manage.py migrate

Adminユーザーの作成

OAuthプロバイダを追加設定するためには、Adminユーザーが必要なため作っておきます。

$ ./manage.py createsuperuser

テストサーバの起動

$ ./manage.py runserver 8000

OAuthプロバイダの設定

プロバイダの設定ページにアクセスするために、一度 Admin サイトにログインしておきます。

http://127.0.0.1:8000/admin/

Admin サイトにログイン後、アプリ追加ページを開きます。

http://127.0.0.1:8000/o/applications/

Click here をクリックします。

Register a new application ページとなるので、

Name: test-consumer (何でも良い)
Client type: Confidential
Authorization grant type: Authorization code
Redirect urls:
http://localhost:8001/accounts/testprovider/login/callback/
http://127.0.0.1:8001/accounts/testprovider/login/callback/

このように入力し、saveします。

作成した OAuthプロバイダは、このツールで簡単なテストができます。

client_id に先ほどの client_id をコピペし、
Authorization url は http://localhost:8000/o/authorize/ を入れればなんとなくのテストができます。

テストユーザーの作成

Admin から、テストユーザーを作っておきます。

http://127.0.0.1:8000/admin/auth/user/

ADD USER をクリックし、適当なユーザーを作っておいてください。
OAuth2 でのログイン時、このユーザーID/パスワードでログインを行うことになります。

引き続き、OAuthコンシューマの開発を行います。

起動中の Django テストサーバは、そのまま起動させっぱなしにしておいてください。

OAuth2コンシューマテストアプリの作成

Django プロジェクトの作成

$ cd test-oauth

$ django-admin.py startproject test_consumer
$ pip install django-allauth

$ cd test_consumer

ここでは、OAuth クライアントライブラリ django-allauth をインストールしています。

django-allauth は多彩はクライアント接続に対応した、多機能なライブラリで、django1.9 + Python 3.5 でも動きますので使い勝手が良いです。

django-allauth のドキュメントはここ

設定

http://django-allauth.readthedocs.io/en/latest/installation.html

test_consumer/settings.py

TEMPLATES の OPTIONS の context_processors に追加

'django.template.context_processors.request',

Django 1.9 なら、デフォルトで入ってると思います。

INSTALLED_APPS に追加

    'django.contrib.sites',
    'test_consumer',
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.google',
    'allauth.socialaccount.providers.twitter',
    'testprovider',  # これから作る

allauth.socialaccount.providers.* は任意に選んで追加してください。
テストのために google と twitter を追加しています。

どんなプロバイダが準備されているかは公式ドキュメントに書いてあります。

http://django-allauth.readthedocs.io/en/latest/installation.html

末尾に追加

AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'allauth.account.auth_backends.AuthenticationBackend',
)

SITE_ID = 1
SESSION_COOKIE_NAME = 'test-consumer-session-id'

test_consumer/urls.py

from django.conf.urls import url, include
from django.contrib import admin
from django.views.generic import TemplateView

urlpatterns = [
    url(r'^admin/', admin.site.urls),

    url(r'^$', TemplateView.as_view(template_name='index.html')),
    url(r'^accounts/', include('allauth.urls')),

]

テンプレートの作成

$ mkdir test_consumer/templates
test_consumer/templates/index.html を作成

{% load socialaccount %}
{% load account %}

<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<a href="{% provider_login_url "twitter" %}">Twitterでログイン</a><br />
<a href="{% provider_login_url "google" %}">Googleでログイン</a><br />
<a href="{% provider_login_url "testprovider" %}">Test Provider でログイン</a><br />

<hr />
{% if user.is_authenticated %}
  ログインユーザー: {% user_display user %}<br />
  {% for sa in user.socialaccount_set.all %}
    {{ sa.extra_data }}<br />
  {% endfor %}
{% endif %}
</body>
</html>

testprovider アダプタの作成

allauth に入っている、google のアダプタを改修して作るのが最も手軽でしょう。

allauth/socialaccount/providers/google このディレクトリをまるごとコピーし、
testprovider としてペーストします。 ( manage.py がいるディレクトリに)

内容は以下のようになります。

testprovider/provider.py

from allauth.socialaccount import providers
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider

class TestAccount(ProviderAccount):

    def to_str(self):
        dflt = super(TestAccount, self).to_str()
        return self.account.extra_data.get('name', dflt)

class TestProvider(OAuth2Provider):
    id = 'testprovider'
    name = 'Test Provider'
    account_class = TestAccount

    def get_default_scope(self):
        return ['read', 'write']

    def get_site(self):
        settings = self.get_settings()
        return settings.get('SITE', 'testprovider')

    def extract_uid(self, data):
        uid = str(data['user_id'])
        return uid

    def extract_common_fields(self, data):
        return dict(username=data.get('email', 'no name'))

providers.registry.register(TestProvider)

testprovider/urls.py

from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
from .provider import TestProvider

urlpatterns = default_urlpatterns(TestProvider)

testprovider/views.py

import requests

from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
                                                          OAuth2LoginView,
                                                          OAuth2CallbackView)
from allauth.socialaccount.providers import registry

from .provider import TestProvider

from django.conf import settings

server_url_prefix = getattr(
    settings, 'TEST_PROVIDER_URL_PREFIX',
    'http://127.0.0.1:8000')

class TestOAuth2Adapter(OAuth2Adapter):
    provider_id = TestProvider.id
    access_token_url = server_url_prefix + '/o/token/'
    authorize_url = server_url_prefix + '/o/authorize/'
    profile_url = server_url_prefix + '/api/profile/'

    def complete_login(self, request, app, token, **kwargs):
        provider = registry.by_id(app.provider)
        resp = requests.get(self.profile_url,
                            params={'access_token': token.token})

        extra_data = resp.json()
        return self.get_provider().sociallogin_from_response(
            request, extra_data)

oauth2_login = OAuth2LoginView.adapter_view(TestOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(TestOAuth2Adapter)

DBのマイグレーション

$ ./manage.py migrate

Adminユーザーの作成

$ ./manage.py createsuperuser

テストサーバの起動

$ ./manage.py runserver 8001

アプリケーションの登録

http://127.0.0.1:8001/admin/

Admin サイトにログイン後、SOCIAL ACCOUNTS の Social applications の +Add をクリックします。

Provider: Test Provider
Name: Test Provider
Client id: 先ほどの 8000 admin で作った Client id
Secret key: 先ほどの 8000 admin で作った Secret key
Key: 空
Sites: example.com を選択

これで、Save します。

Google, Twitter など他サービスへのログインも必要であれば、ここから追加できます。

クライアントとしての動作テスト

先ほど、8000ポートにログインしたブラウザとは別のブラウザを使います。(ログアウト状態になっているため)

http://127.0.0.1:8001/

ここで、「Test Provider でログイン」をクリックします。

( AllAuth が自動的にログインフォームを提供してますので、このURLからでもいけます。/accounts/login/ )

そうすると、8000 ポートへ転送され、ログインフォームが出てきます。
今回は時間節約のため、Django Admin のログインフォームを流用していますが、
通常は自作のログインフォームをここで表示させることになると思います。

8000 (test_provider) で作ったテストユーザーでログインしてみましょう。

ログインが成功すると、認可を求めるページとなりますので、「Authorize」をクリック。
ちなみに、この認可を求めるページは、8000 (test_provider) の Admin の アプリ設定の、「Skip authorization」にチェックを入れると省略できます。

ログインに成功すると、8001 ポートに戻ってきます。

http://127.0.0.1:8001/accounts/profile/
が表示されますが、このページはまだ作っていませんので Not found になります。

情報取得は成功していますので、トップページのURLを手で入力して情報を見てみます。

http://127.0.0.1:8001/

トップページ下部に、API取得した extra_data を表示する箇所があります。
そこに、OAuth2 のプロバイダから取得した情報が表示されるのが確認できます。

OAuthプロバイダの /api/profile/ から取得した 'secret_message': 'The quick brown fox' が、OAuthコンシューマで取得できているのが確認できます。

ソースコードは Github に上げました。

Django Mezzanine で、複数サイトを共存させ同時に運用する

はじめに

Mezzanine とは、Django (Python のWeb フレームワーク )上で動く、CMS フレームワークです。使い勝手としては Wordpress に似ています。

(英語) http://mezzanine.jupo.org/docs/index.html

最初から入っている機能としては

  • WYSIWYG エディタ ( Tiny MCE ) が入っているのでリッチテキストコンテンツが簡単に投稿できる
  • インラインページ編集 … 管理者ログインしていれば、公開済みページの各セクションに編集ボタンが出て編集可能
  • Twitter Bootstrap
  • Disqus連携
  • Gravatar連携
  • Twitter連携

などがあり、そもそも Django なのでテーマの切り替えが簡単だったり、プラグインの入れて機能を拡張したりなどが容易にできます。プラグイン( Django モジュール) の開発者は世界中に多く存在します。

その Mezzanine の機能の中で、少し難解ですが便利な機能に「Multi Tenancy」というものがあります。1つの Mezzanine の中で、複数サイトを運営できる機能です。

このブログも Mezzanine の マルチテナンシーで運営しています。例えば、株式会社TORICO のサイト , TORICOの技術部ブログまんが展公式サイトホーリンラブブックスブログ などは、一つの Gunicorn プロセスから出力しています。

公式サイトの説明は ここ(英語) にあるのですが、テンプレートの説明しかなくてわかりにくいですね。

このマルチテナンシーは Django 標準の Site フレームワークを使いやすく拡張した機能です。Site フレームワークについては Djangoドキュメントに説明があります。(英語)

一昔前はあまり詳しい説明が書いてなかった記憶があるのですが、執筆時現行バージョンの Django 1.9 のドキュメンテーションでは丁寧に書かれていますね。

とはいえ、Django は使っていても Site は使ったことがない方が多いと思います。私もそうで、Django を業務で使っていた今まで5年間、まったく活用したことがありませんでした。というか、使い方を知りませんでした。Mezzanine は、実用的な応用例として参考になると思います。

Mezzanineのセットアップ (省略)

Mezzanine の セットアップについてはこの記事では言及しません。

公式サイト部右ブロックに、「QUICK START」という欄があるので、これに沿ってコマンドを実行すればデモページはすぐ動きますし、Qiita に良記事があります。
Mezzanineをはじめよう - Qiita

nginx の設定

公開環境で動かすには、gunicorn (や uwsgi ) 上で django を起動し、nginx で80ポートを受けて gunicorn がリッスンしている 8000 ポートあたりに転送する感じでしょう。マルチテナンシー運用では、複数ドメインを宛先としたリクエストをすべて nginx から同じ gunicorn に転送します。

リクエストのドメイン名からサイトを判定する都合上、Web サーバから Django アプリケーションへのリクエストの際に、ユーザーエージェントがリクエストする HOST ヘッダを失うことなく Django アプリケーションに伝える必要があります。

Django でリクエストホストを取得する処理は django.http.request.get_host にあるのですが、

1. X_FORWARDED_HOST ヘッダがあればそれを使う
2. 無ければ、HOST ヘッダを使う

という処理になっていますので、nginx の設定は

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

このように書くと良いでしょう。

全体的な nginx の設定はこのようになります。

server {
    listen 80 default;
    listen 443 ssl;

    ssl_certificate ...........;
    ssl_certificate_key ...........;

    access_log  ...........;
    error_log  ...........;

    # ローカルの gunicorn にプロキシ
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
    # Django の static_url 転送
    location ~ ^/static(/(.+))?$ {
        alias ...........$1;
        break;
    }
    # Django の media_url 転送
    location ~ ^/media(/(.+))?$ {
        alias ...........$1;
        break;
    }
}

# ネイキッドドメインリダイレクト
server {
    server_name torico-corp.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.torico-corp.com$request_uri;
}
server {
    server_name manga10.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.manga10.com$request_uri;
}

サイト切り替えの仕組みの基礎

Mezzanine 内でサイトIDを判定するために、1リクエスト内で何度も評価されるメソッドがあります。

mezzanine.utils.sites の current_site_id がそれです。

読んでみると、

1. スレッドローカルに保存されている request をキャッシュとして使い、その中に site_id があるか調べる。あれば使う。
2. 無ければ、セッション変数に site_id があるか調べる。あれば使う。
3. Django のキャッシュフレームワークにあるか調べる。あれば使う。
4. 無ければ、リクエストのドメインから Site モデルを検索し、一致すればその id を使う。
※ Site モデルは、DBから取得した後はローカルメモリにキャッシュする。
5. 無ければ、環境変数 MEZZANINE_SITE_ID
6. 無ければ、Django settings の SITE_ID
site_id を見つけたら、適宜キャッシュに保存する。

という順番で評価していくのがわかると思います。このコードの評価順は重要なので、何度も読み返すことになるでしょう。

複数の Site の登録

まず、Mezzanine が動きましたら、管理サイト (デフォルトでは /admin/ ) にログインします。

管理サイトの左ナビ部に、「サイト」がありますのでそれをクリックし、サイトを追加します。


すると、管理サイト上部にサイト切り替えプルダウンが表示されます。

これを切り替えると、編集対象とするサイトを変えることができます。
これは、選択した site_id をセッション内に記録します。

サイトごとに内容を切り替えるモデルの仕組み

BlogPost モデル (ブログ投稿) を見るとわかりますが、Mezzanine に含まれるモデルはほぼすべて、SiteRelated モデルを継承しています。

SiteRelated ← Slugged ← Displayable ← BlogPost

この SiteRelated モデルは、独自のモデルマネージャ CurrentSiteManager を持っており、これを継承したモデル(例: BlogPost ) でクエリセットを操作すると、自動的に WHERE site_id = ? の SQL が付与されます。また、新規保存する際にも、自動的に site_id = ? の SQL を加えて保存します。

site_id は、上記の「mezzanine.utils.sites.current_site_id」から取得します。ここで、セッション変数やリクエストのドメイン名から mezzanine が自動的に site_id を判定してくれるので、意識せずとも複数サイトを切り替えることができるわけです。

そのため、自分で Django モデルを追加した場合は、この SiteRelated を継承して作る必要があります。
単純に SiteRelated を継承するだけで、管理サイトのサイト切り替えなどに対応したモデルを簡単に作ることができます。

サイト別に、固有設定をもっと定義したい

Site モデルには、ドメインと名前のフィールドしかありません。実際にはもっと多くのサイト別情報を管理する必要があるでしょう。

Site に紐付いた付加情報を扱うモデルも、Mezzanine に用意されています。

mezzanine.conf モジュールがそれで、Site 別に、キーバリューストアがあるイメージです。

ドキュメンテーションは ここ にあり、使用例は
mezzanine/accounts/defaults.py を見ると良いでしょう。

from mezzanine.conf import register_setting

register_setting(
    name="ACCOUNTS_MIN_PASSWORD_LENGTH",
    description=_("Minimum length for passwords"),
    editable=False,
    default=6,
)

このように、各 Django アプリの defaults.py で、register_setting を呼ぶことで キー (name)を設定します。

ここで定義したものは、Admin サイトの「設定」にフォームフィールドが表示されます。

オートグルーピング

Mezzanine の管理サイトの、「設定」を見てみると、デフォルトでは「コメント」「SSL」「Twitter」などがグループ化されています。

自分で register_setting した場合、そのままだと設定ページの「その他」(Miscellaneous) グループに含まれてしまいます。自分でグルーピングする手段がわかりにくいですが、グルーピングは name の値によって自動的に行われます。1つめの _ (アンダーバー) の前がグループ名となり、複数あるようなら自動的にグループ化されます。

例えば

from mezzanine.conf import register_setting

register_setting(
    name="TEMPLATE_SITES_HOGE",
    label="テンプレートの…",
    editable=True,
    default="",
)
register_setting(
    name="TEMPLATE_SITES_FUGA",
    label="テンプレートの…",
    editable=True,
    default="",
)

このように register_setting した場合、Admin サイト内では

この画像のように、Template という名前でグループされます。便利ですね。

mezzanine.conf.settings ( conf_settingsテーブル) の読み出し方

上記管理サイトで設定した値は、

from mezzanine.conf import settings
settings.TEMPLATE_SITES_HOGE

でアクセスできます。

django.conf.settings と名前が似ていて混乱しそうですが、mezzanine プロジェクトでは django.conf.settings は使う必要がありません。mezzanine.conf.settings は、django.conf.settings の完全な代わりとして使えます。

このコードの場合、まず TEMPLATE_SITES_HOGE キーが conf_settings テーブルにあるか(現在の site_id で絞り込んで)調べ、無ければ django.conf.settings の値を使う、という動作になります。

なお、DB の検索結果はメモリにキャッシュされますので、頻繁にアクセスする場合も安心です。

テンプレートから使う

mezzanine.conf.settings をテンプレートから使う場合は、コンテキストプロセッサ ( mezzanine.conf.context_processors ) が用意されていますので、特に設定不要で

{{ settings.XXXXXX }}

で設定値にアクセスできます。

ただし、このコンテキストプロセッサを読むと、「ホワイトリストで許可された設定値のみ読み出せる」という処理になっているのがわかると思います。

そのホワイトリストは、mezzanine/core/defaults.py を TEMPLATE_ACCESSIBLE_SETTINGS で検索すると出てきますが、

register_setting(
    name="TEMPLATE_ACCESSIBLE_SETTINGS",
    description=_("Sequence of setting names available within templates."),
    editable=False,
    default=(
        "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
        "ADMIN_MENU_COLLAPSED",
        "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
        "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
        "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
        "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
        "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
        "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
        "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    ),
)

こうなっています。

そのため、独自に register_settings した値は、このホワイトリストに追加しないとコンテクストプロセッサ経由でアクセスできません。

このホワイトリスト TEMPLATE_ACCESSIBLE_SETTINGS に値を追加するには、自分の App 内の defaults.py に、上記の register_setting をコピーして貼り付け、default= に値を追加しても良いですし、Django の settings.py に

TEMPLATE_ACCESSIBLE_SETTINGS = (
    "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
    "ADMIN_MENU_COLLAPSED",
    "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
    "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
    "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
    "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
    "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
    "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
    "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    # =================================
    # 追加分
    "TEMPLATE_SITES_HOGE", "TEMPLATE_SITES_FUGA
)

このように書いておいても読み出されます。

(デフォルト値をベタッと書いているのがメンテナンス性低そうで嫌ですが、良い方法が思いつきませんでした)

これで、テンプレートで

{{ settings.TEMPLATE_SITES_HOGE }}

ができるようになります。

全サイトを回すバッチの作り方

Sites に複数のサイトを登録してある環境で、Django のマネジメントコマンドでバッチを動作させたいとします。

普通にマネジメントコマンドを書いて実行すると、現在の site_id は settings.SITE_ID に書いた値のみ使われることになります。この場合、全サイトを対象にバッチを動作させることができません。

そこで、環境変数 MEZZANINE_SITE_ID を使います。

私は、このようなメソッドを用意してやっています。

def set_site_id(site_id):
    os.environ["MEZZANINE_SITE_ID"] = str(site_id)

def clear_current_request():
    """
    Mezzanine のスレッドローカルリクエストを消去
    """
    from mezzanine.core.request import _thread_local
    if hasattr(_thread_local, "request"):
        setattr(_thread_local, "request", None)

def all_sites():
    """
    全site を、有効なサイトを設定しながらループする
    :return:
    """
    for site in Site.object.all().order_by("id"):
        clear_current_request()
        set_site_id(site.id)
        yield site

この場合、使い方としては

for site in all_sites():
    1つのサイトに対するバッチ処理...

このようになり、ループ毎に環境変数の MEZZANINE_SITE_ID をセットしながら回すことができます。

スレッドローカルの "request" を消去するのは、ユニットテスト用です。

同じスレッドで過去にビュー関数を呼ぶテストが行われていた場合、スレッドローカルに request が残り誤動作をしてしまうので、消してます。

Admin 管理サイトから、特定のサイトだけ左メニューからリンクを消す

モデルを作り、admin.py を作るなどして管理サイトから編集できるようにすると、マルチテナンシーの場合はすべてのサイトで編集リンクが出ますが、特定のサイトのみ管理サイト項目を有効化(もしくは無効化) させたいケースはあると思います。

管理サイトに項目を表示するかどうかは、Admin モデルの in_menu メソッドで評価するようになってますので、例えば Contact というモデルを作り、ContactAdmin という Admin モデルをひも付けた場合は

from django.contrib import admin
from mezzanine.utils.sites import current_site_id

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return current_site_id() in [2, ]


admin.site.register(models.Contact, ContactAdmin)

(ちょっと上の例は泥臭い例ですが)このように、in_menu メソッドを作り、その中で current_site_id() を判断するとか、

register_setting(
    name="CONTACT_ENABLED",
    editable=True,
    default=True,
)

defaults.py なんかで、上記のように設定フラグを定義して、

from django.contrib import admin
from mezzanine.conf import settings

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return settings.CONTACT_ENABLED


admin.site.register(models.Contact, ContactAdmin)

その設定値を使って項目を出し分けることもできます。

mezzanine のビルトインモデルを Admin で出し分けるには

モンキーパッチでできます。他に良い方法ありそうですが知りません。

def patch_blog_post_admin():
    """
    BlogPost site_id = 7 では消す
    """
    from mezzanine.blog.admin import BlogPostAdmin

    def _in_menu(self):
        return _current_site_id() not in [7, ]

    BlogPostAdmin.in_menu = _in_menu

patch_blog_post_admin()

Admin 表示前に、上記関数が評価されていれば、site_id == 7 の場合、BlogPost が表示されなくなります。

Search