読者です 読者をやめる 読者になる 読者になる

2012 Pythonアドベントカレンダー 19日目 pyramidでseparation

これはpython advent calendar 2012の19日目の記事です。

何についての話?

pyramidを使ったseparationの話です。間接参照すばらしい的な話につなげられたら良いです。 (MVC的にはsqlalchemy,mako,pyramid(のURLディスパッチ)を利用してます)

 ある学生寮向けのローカルSNSの開発

一昔前に流行ったローカルSNSのようなものを作ることになった。 ある学生寮の住人向けに作られるそうだ。そこに所属する学生が書いた日記などをまとめたりなどしたいらしい。

これは、住人の一覧画面の表示に使うview。他にも同様の形のviewがいくつも定義されている。

from pyramid.view import view_config

@view_config(route_name="index", renderer="advent:templates/index.mako")
def index_view(context, request):
    students = context.Student.query
    return {"students": students}

特に目新しいこともない普通のviewだ。 1つあるとすれば、全てのviewはcontext.Student.queryからモデルのクエリーを取り出している。

pyramidを使ったことがない人のために説明しておくと、pyramidのviewはcontextとrequestを受け取りresponseの生成元を返す関数になっている。 responseの生成元とは単純にはresponseそれ自身のことであるし、今回の場合はテンプレートエンジンのテンプレートの引数として与えられる辞書のこと。 contextはrequestを引数に取るオブジェクトのこと。特に経路制御に(traversalではなく)URLディスパッチを利用している場合には、それ以外決められたことは何もない。

1度目のリリース。それなりに好評。separationの機能の追加を頼まれる

1度目のリリースが終わった。それなりに好評であったらしい。 この学生寮の近くには同様の寮があと3つあるそうだが、他の寮でもこのシステムを使いたいらしい。

現在のところ、ユーザ数もあまり多くなく、潜在的なユーザを含めても1台分の環境で十分お釣りが来る程度のリソースの圧迫で済んでいるようだ。 新たに管理する環境を増やすのは手間だと言うので、現在利用しているシステム1つで他の寮のものも補って欲しいらしい。

ただし、条件がひとつあるそうだ。 それは、「各寮の住人が閲覧できるのは、所属している寮のものに限る」というものだそうだ。 つまるところ、separationの機能を付けて欲しいらしい。

具体的には、「システムの利用者(寮の住人)にはログインユーザはgroup(寮)に所属し、そのgroup以外の情報が目に写ってはならないというもの」 単純には以下のようなフィルタリングをかける必要がある。

Student.query.filter(Student.group_id==login_group_id)

言われた通りのseparationを提供するには、Studentを利用している全てのqueryで、このようなフィルタリングを追加する必要がでてきた。 数えてみたところ修正が必要な箇所が20〜30位あった。これら全てにフィルタリングを付けて回るのは辛そうだ。

contextから取得している

実際のところ、冒頭のviewで出てきたcontext.Student.queryというコード片は以下のようなContextが使われていた

from . import models

class DefaultResource(object):
    def __init__(self, request):
       self.request = request

    Student = models.Student
    Group = models.Group

modelsのStudentをクラスの中で同名の変数に代入しているだけだった。 これだけでは直接viewでmodels.Studentを使うのと変わりはない。

ログインしたユーザのgroup_idは取得するAPIを用意していた。これを使って以下のように書き換える。

from pyramid.decorator import reify

from . import models
from . import api

class ModelProxy(object):
    def __init__(self, model, query=None):
        self.model = model
        self.query = query or model.query

   def __getattr__(self, k):
       return getattr(self.model, k)

class DefaultResource(object):
    def __init__(self, request):
       self.request = request

    @reify
    def Student(self):
        login_group_id = api.get_login_group_id(self.request)
        query = models.Student.query.filter(models.Student.group_id==login_group_id)
        return ModelProxy(models.Student, query=query)
    Group = models.Group

reifyはキャッシュされたpropertyを作成するもの。同一view中では、一度返されたStudentの値がキャッシュされ、再度呼び出された時には同じものを返す。 これでフィルタリングができた。 全てのviewはcontext.Student.queryからモデルのクエリーを取り出している。 (この前提が守られていれば)、viewから見たStudentは全てフィルタリング済みのqueryということになる。

次の改修。各寮はそれぞれ独自のレイアウトを設定したい

2度目のリリースをした。separationの機能も付きそれなりに満足してもらえたようだ。 ところが新たに注文がやってきた。

各寮の担当者それぞれが自身の寮の独自性を出したいそうだ。つまりは独自のテーマを設定したいらしい。

独自のテーマを設定したいというのは - 各ページについて背景色を各寮に馴染んだ色を設定したい - トップページ(などに)独自のgreeting messageを表示したい ということらしい。

今度は、フィルタリングだけではなく、各寮ごとに背景色などを設定したいらしい。

pyramid_layoutが便利

pyramid_layoutを使ってみる。 pyramid_layoutにはLayout,Panelという機能があるが今回使うのはLayoutの方だけ。

pyramid 1.4未満の場合は以下のような形になる。

from pyramid.view import view_config
from pyramid_layout.config import LayoutPredicate

layout_predicate = lambda v : LayoutPredicate(v, None)
def group_predicates(group_name):
    def _group_predicates(context, request):
        return context.login_group_name == group_name
    return _group_predicates

@view_config(custom_predicates=(group_predicates("red"), layout_predicate("red")), route_name="index", renderer="advent:templates/index.mako")
@view_config(custom_predicates=(group_predicates("blue"), layout_predicate("blue")), route_name="index", renderer="advent:templates/index.mako")
@view_config(custom_predicates=(group_predicates("green"), layout_predicate("green")), route_name="index", renderer="advent:templates/index.mako")
@view_config(custom_predicates=(group_predicates("yellow"), layout_predicate("yellow")), route_name="index", renderer="advent:templates/index.mako")
def index_view(context, request):
    students = context.Student.query
    return {"students": students}

group_idはgroup_nameに変わっている。結局のところ同じものでid=1がname="red"に変わっているようなもの。 ちなみに1.4からはconfig.add_view_predicateが追加され、view_configに独自のオプションをもたせられるので、以下のように書けるようにすることもできる。

@view_config(group_name="red", layout="red", route_name="index", renderer="advent:templates/index.mako")
@view_config(group_name="blue", layout="blue", route_name="index", renderer="advent:templates/index.mako")
@view_config(group_name="green", layout="green", route_name="index", renderer="advent:templates/index.mako")
@view_config(group_name="yellow", layout="yellow", route_name="index", renderer="advent:templates/index.mako")
def index_view(context, request):
    students = context.Student.query
    return {"students": students}

これで赤色の寮の学生がアクセスすれば、赤色が背景の画面が手に入り。 緑色の寮の学生がアクセスすれば、緑色が背景の画面が手に入る。

例えば、赤色用のレイアウトは以下のようになっている。

class IndexLayout(object):
    css = ""
    description = "<description>"

    def __init__(self, context, request):
        self.context = context
        self.request = request

class RedIndex(IndexLayout):
    css = u"""td {background-color: #ffaaaa;}"""
    description = u"red"

これをアプリケーションが立ち上がるタイミングで設定するコードを追加する。

config.add_layout("advent.layouts.RedIndex", template="advent:templates/index.mako", name="red")

テンプレートの継承元を変えることができる

すぐ上のコードでは、レンダリングに利用するテンプレートが"advent:templates/index.mako"で add_layoutに渡されるテンプレートのパスも"advent:templates/index.mako"だった。

実はこれは無駄なことをしている。add_layoutに渡されたテンプレートは暗黙の内にcontext["main_template"]の中に入る。 なので、レンダリングに利用するテンプレートが継承を使っているのなら、その部分を以下のように書き換えることで、 赤色の寮の学生のアクセスにはred_base.makoを利用し,黄色い寮の学生のアクセスにはyellow_base.makoを使うことというようにすることもできる。

## advent:advent/advent/templates/index.mako
<%inherit file="${context['main_template'].uri}"/>

まとめ

  • separationどうしよう。間接参照便利
  • さらに巨大な敵。pyramid_layout便利

github

この記事を書くために作ったリポジトリがあります。ほとんど何も無いに等しいですが。参考になれば。