djangoのチュートリアルをさくっとやってみた(管理サイト構築)

お仕事の事情でpythonエンジニアにスキルチェンジが求められたので
awsにさくっとdjango環境を構築してチュートリアルを行いました。
djangoのチュートリアル

1. サーバ環境準備
awsにマイクロインスタンスでAmazon Linuxを立てました。
対象サーバにログインしてから必要モジュールをインストールします。

// mysql,python2.7 gccをインストール
$ sudo yum install mysql-server mysql-devel gcc
$ sudo yum install python27 python27-devel python-setuptools
$ sudo easy_install pip
// mysql起動
$ sudo service mysqld start
// virtualenvでテスト環境に必要モジュールをインストール
$ sudo pip install virtualenv
$ virtualenv -p /usr/bin/python27 --no-site-packages ~/.django13
$ source ~/.django13/bin/activate
$ pip install django==1.3.7 flup mysql-python

2. チュートリアル用プロジェクトの準備
mysqlにdjango用ユーザを追加

$ mysql -u root
mysql> CREATE DATABASE django;
mysql> GRANT ALL PRIVILEGES ON django.* TO django@localhost IDENTIFIED BY 'django';
mysql> FLUSH PRIVILEGES;

プロジェクト用のディレクトリ(/home/django13/taka512)を作成し、mysqlへの接続設定を追加

$ sudo mkdir /home/django13
$ sudo chmod 777 /home/django13
$ cd /home/django13
$ django-admin.py startproject taka512
$ cd /home/django13/taka512
// mysqlへの接続設定を追加
$ vi settings.py
 12 DATABASES = {
 13     'default': {  
 14         'ENGINE': 'django.db.backends.mysql',
 15         'NAME': 'django',
 16         'USER': 'django',
 17         'PASSWORD': 'django',
 18         'HOST': 'localhost',
 19         'PORT': '3306',
 20     }
 21 }

これで準備は完了です。

3. チュートリアル用アプリの作成
djangoのチュートリアルでは下記機能を備えたウェブアプリを作成します。
(1)投票項目(polls)を追加、変更削除可能な管理(admin)サイト。
(2)人々が投票項目(polls)の参照と投票(vote)が可能な公開サイト

3.1 管理サイトの作成
管理サイトのテーブルとアプリの雛形を作成

// 管理画面用のスキーマをデータベースに作成
// 途中で管理ユーザの作成を聞かれるのでadmin/adminで作成
$ python manage.py syncdb
// アプリケーションの雛形を作成
// (pollsディレクトリに雛形が作成されます)
$ python manage.py startapp polls

3.1.1 modelの作成
下記2つのmodelを作成します。
・投票項目(Poll):質問と発行日を保持
・投票選択肢(Choice):投票の選択肢と集計を保持、各選択肢は投票項目に関連付

$ vi polls/models.py
  1 from django.db import models
  2
  3 class Poll(models.Model):
  4     question = models.CharField(max_length=200)
  5     pub_date = models.DateTimeField('date published')
  6
  7 class Choice(models.Model):
  8     poll = models.ForeignKey(Poll)
  9     choice = models.CharField(max_length=200)
 10     votes = models.IntegerField()

3.1.2 modelの有効化
INSTALLED_APPに「polls」を追加

$ vi settings.py
115 INSTALLED_APPS = (
116     'django.contrib.auth',
117     'django.contrib.contenttypes',
118     'django.contrib.sessions',
119     'django.contrib.sites',
120     'django.contrib.messages',
121     'django.contrib.staticfiles',
122     'polls',

3.1.3 テーブルにmodelを反映

// 実行されるSQLの確認
$ python manage.py sql polls
// テーブル作成
$ python manage.py syncdb

3.1.4 管理サイトの対象にPollを追加

$ vi polls/admin.py
  1 from polls.models import Poll
  2 from django.contrib import admin
  3
  4 admin.site.register(Poll)

3.1.5 投票データの追加
shellからオブジェクトを操作してデータの追加が行う。

$ python manage.py shell
>>> import datetime
>>> from polls.models import Poll, Choice
>>> p = Poll(question="What's up?", pub_date=datetime.datetime.now())
>>> p.save()
>>> p.choice_set.create(choice='Not much', votes=0)
>>> p.choice_set.create(choice='The sky', votes=0)
>>> p.choice_set.create(choice='Just hacking again', votes=0)

3.1.6 管理サイトの有効化
INSTALLED_APPSに「django.contrib.admin」を追加し、ルーティングも有効にします。

$ vi settings.py
115 INSTALLED_APPS = (
116     'django.contrib.auth',
117     'django.contrib.contenttypes',
118     'django.contrib.sessions',
119     'django.contrib.sites',
120     'django.contrib.messages',
121     'django.contrib.staticfiles',
122     'polls',
123     'django.contrib.admin',
124 )
// 管理サイト用のルーティングを追加
$ vi urls.py
  1 from django.conf.urls.defaults import patterns, include, url
  2
  3 from django.contrib import admin
  4 admin.autodiscover()
  5
  6 urlpatterns = patterns('',
  7     url(r'^admin/', include(admin.site.urls)),
  8 )

3.1.7 ウェブサーバ起動
起動後、自分のawsのurlにアクセスするとログイン画面が表示され先ほど追加したユーザでログインできます。
http://ec2-X-X-X-X.ap-northeast-1.compute.amazonaws.com/admin/

$ sudo /home/ec2-user/.django13/bin/python manage.py runserver 0.0.0.0:80

3.1.8 管理サイトのmodel項目の変更
管理サイトのmodelの項目はadmin.pyを編集する事で変更可能です。

// 質問と日付の表示順序を変更
$ vi polls/admin.py
  1 from polls.models import Poll
  2 from django.contrib import admin
  3
  4 class PollAdmin(admin.ModelAdmin):
  5     fields = ['pub_date', 'question']
  6
  7 admin.site.register(Poll,PollAdmin)

// 日付に「Date information」とタイトルバーをつける
$ vi polls/admin.py
  4 class PollAdmin(admin.ModelAdmin):
  5     fieldsets = [
  6         (None,               {'fields': ['question']}),
  7         ('Date information', {'fields': ['pub_date']}),
  8     ]

// htmlクラスを指定して日付を折り畳みに変更
$ vi polls/admin.py
  4 class PollAdmin(admin.ModelAdmin):
  5     fieldsets = [
  6         (None,               {'fields': ['question']}),
  7         ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}),
  8     ]

// 普通にChoiceを編集対象に追加
$ vi polls/admin.py
 11 from polls.models import Choice
 12 admin.site.register(Choice)

// Pollに関連付けてChoiceを表示
$ vi polls/admin.py
  1 from django.contrib import admin
  2 from polls.models import Poll
  3 from polls.models import Choice
  4
  5 class ChoiceInline(admin.StackedInline):
  6     model = Choice
  7     extra = 3
  8
  9 class PollAdmin(admin.ModelAdmin):
 10     fieldsets = [
 11         (None,               {'fields': ['question']}),
 12         ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}),
 13     ]
 14     inlines = [ChoiceInline]
 15 admin.site.register(Poll,PollAdmin)

// Choiceの表示を変更
$ vi polls/admin.py
  5 class ChoiceInline(admin.TabularInline):
  6     model = Choice
  7     extra = 3

一覧の表示を変更する場合は以下のように行います。

// 一覧の表示項目のプロパティを追加
$ vi polls/models.py
  1 from django.db import models
  2 import datetime
  3
  4 class Poll(models.Model):
  5     question = models.CharField(max_length=200)
  6     pub_date = models.DateTimeField('date published')
  7
  8     def was_published_today(self):
  9         return self.pub_date.date() == datetime.date.today()
 10     was_published_today.short_description = 'Published today?'

// 一覧の表示項目が変更可能
// list_displayで表示項目
// list_filterでフィルター項目
// search_fieldsで検索項目
// date_hierarchyで日付のページめくり項目
$ vi polls/admin.py
  9 class PollAdmin(admin.ModelAdmin):
 10     list_display = ('question', 'pub_date', 'was_published_today'
 11     list_filter = ['pub_date']
 12     search_fields = ['question']
 13     date_hierarchy = 'pub_date'

3.1.9 管理サイトの見た目の変更
サイトのタイトルを書き換えてみます。
タイトル欄が「taka512 administration」と表示されれば成功です。

$ vi settings.py
108 TEMPLATE_DIRS = (
109     '/home/django13/taka512/templates',
110 )

$ mkdir -p templates/admin
$ cp ~/.django13/lib/python2.7/site-packages/django/contrib/admin/templates/admin/base_site.html templates/admin/
$ vi templates/admin/base_site.html
  7 <h1 id="site-name">{% trans 'taka512 administration' %}</h1>

以上でチュートリアルの管理サイト構築は完了です。

slimでknp-componentsのpaginatorを使う(データベースアクセス版2)

前回の記事ではページ捲り用のクラスを作ってページ捲り機能を実装してました。
今回はデータの取得/計算をコントローラで行いpagination機能だけを使いたい場合の例を記します。
素のphpの場合、この実装がやりやすいと思います。

Controllerの変更
リミット(limit) , 総件数(count),ページ数(page), データ(items)の取得/計算を行います。
テンプレートは前回から修正の必要はありません。

$ vi src/Taka512/Controllers/ContentsController.php
<?php
namespace Taka512\Controllers;
use Knp\Component\Pager\Pagination\SlidingPagination;

class ContentsController
{
~省略~
    public function pageTest($page) {
        $limit = 5;
        $offset = abs($page - 1) * $limit;

        $sth = $this->dbh->prepare('SELECT COUNT(*) AS count FROM alphabet');
        $sth->execute();
        $count = 0;
        while($row = $sth->fetch(\PDO::FETCH_ASSOC)){
            $count = $row['count'];
        }

        $items = array();
        if ($count) {
            $sth = $this->dbh->prepare('SELECT * FROM alphabet ORDER BY SORT LIMIT ? OFFSET ?');
            $sth->execute(array($limit, $offset));
            while($row = $sth->fetch(\PDO::FETCH_ASSOC)){
                $items[] = $row;
            }
        }

        $pagination = new SlidingPagination();
        $pagination->setCurrentPageNumber($page);
        $pagination->setItemNumberPerPage($limit);
        $pagination->setTotalItemCount($count);
        $pagination->setItems($items);
        $pagination->setCustomParameters(array());
        $pagination->setPaginatorOptions(array());
        $this->app->render('page_test.html.twig', array('pagination' => $pagination));
    }


こんな感じでknp-componentsは理解すると便利に利用できます。

slimでknp-componentsのpaginatorを使う(データベースアクセス版)

前回の記事では最初から全データを用意してページ捲りを実装してました。
ただ、データベースに対象データが存在する場合は全件データを取得するのは現実的ではありません。
データベースからデータの一部を取得する形でページ捲りを実装する方法を記します。

1. データの投入
aからzまでのデータが存在するalphabetテーブルを用意します。

mysql> create table alphabet( str varchar(1), sort int);
mysql> insert into alphabet(str, sort) values('a', 1);
mysql> insert into alphabet(str, sort) values('b', 2);
~省略~
mysql> select * from alphabet;
+------+------+
| str  | sort |
+------+------+
| a    |    1 |
| b    |    2 |
| c    |    3 |
| d    |    4 |
| e    |    5 |
| f    |    6 |
| g    |    7 |
~省略~

2. DoctrineのORMを使う場合の実装例
DoctrineのORM等を利用する場合はpaginateの第1引数にDoctrineのQueryを指定すれば完了です。

$ vi src/Taka512/Controllers/ContentsController.php
~省略~
    public function pageTest($page) {
        $paginator = new Paginator();
        $pagination = $paginator->paginate(
            $this->em->createQuery('SELECT a FROM Entity\Alphabet a'), $page, 5);
        $this->app->render('page_test.html.twig', array('pagination' => $pagination));
    }

3. PDOを使う場合の実装例
knp-componentsのページ捲りはSymfony2のEventDispatcherのcomponentsを使用して実装してます。
DoctrineのORMを使用している場合は既存のSubscriberを使用してページ捲り用の処理を行いますが、
PDOの場合は自分でSubscriberを実装する必要があります。

Subscriberの作成
Subscriberではcountでデータ件数とlimitでデータを取得してEventオブジェクトに設定します。

$ mkdir -p src/Taka512/Events/Subscriber
$ vi src/Taka512/Events/Subscriber/PdoQuerySubscriber.php
<?php
namespace Taka512\Events\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Knp\Component\Pager\Event\ItemsEvent;
use Taka512\Models\PdoQuery;

class PdoQuerySubscriber implements EventSubscriberInterface
{
    public function items(ItemsEvent $event)
    {
        if ($event->target instanceof PdoQuery) {

            $sth = $event->target->dbh->prepare('SELECT COUNT(*) AS count FROM alphabet');
            $sth->execute();
            while($row = $sth->fetch(\PDO::FETCH_ASSOC)){
                $event->count = $row['count'];
            }

            $event->items = array();
            if ($event->count) {
                $sth = $event->target->dbh->prepare('SELECT * FROM alphabet ORDER BY SORT LIMIT ? OFFSET ?');
                $sth->execute(array($event->getLimit(),$event->getOffset()));
                while($row = $sth->fetch(\PDO::FETCH_ASSOC)){
                    $event->items[] = $row;
                }
            }
            $event->stopPropagation();
        }
    }

    public static function getSubscribedEvents()
    {
        return array(
            'knp_pager.items' => array('items', 2)
        );
    }
}

Subscriberに渡すデータを保持するクラスを作成します。
本来はここで検索条件などを保持してPdoQuerySubscriberで組み立てますが今回はdbhのみ保持します。

$ mkdir -p src/Taka512/Models
$ vi src/Taka512/Models/PdoQuery.php
<?php
namespace Taka512\Models;

class PdoQuery
{
    public $dbh;
    public function __construct($dbh)
    {
        $this->dbh   = $dbh;
    }
}

Controllerの変更
Controllerでは明示的にsubscriberを指定し、作成したPdoQueryクラスを指定すると
PdoQuerySubscriberのitemsが動作してpaginationデータがセットされるようになります。

$ vi src/Taka512/Controllers/ContentsController.php
~省略~
    public function pageTest($page) {
        $paginator = new Paginator();
        $paginator->subscribe(new PdoQuerySubscriber());
        $query = new PdoQuery($this->dbh);
        $pagination = $paginator->paginate($query, $page, 5);

        $this->app->render('page_test.html.twig', array('pagination' => $pagination));
    }

こんな感じでデータベースを利用したページ捲りが実装できます。

slimでknp-componentsのpaginatorを使う(非データベースアクセス版)

knp-componentsを利用してページ捲りを実装する方法を記す。
今回は[domain]/page/1でアクセスできるページ捲りテスト用のページを作成し、a~zまでのアルファベットを表示するページを作成します。

knp-componentsのインストール

$ vi composer.json
    "require": {
        "slim/slim":       "2.*",
        "slim/extras":     "2.0.*",
        "twig/twig":       "1.*",
        "pimple/pimple":   "v1.0.2",
        "phpunit/phpunit": "3.7.21",
        "fabpot/goutte":   "v1.0.1",
        "knp-components":  "1.2.2"
$ php composer.phar update

routeを追加

$ vi config/routes.php
~略~
$app->get('/page/:page', function($page = 1) use ($container) {
    $container['app.controllers.contents_controller']->pageTest($page);
  });

controllerを実装
a~zの26文字の配列を1ページ5文字で表示するように設定

$ vi src/Taka512/Controllers/ContentsController.php
use Knp\Component\Pager\Paginator;
class ContentsController
{
~略~
    public function pageTest($page) {
        $paginator = new Paginator();
        $target = range('a', 'z');
        $pagination = $paginator->paginate($target, $page, 5);

        $this->app->render('page_test.html.twig', array('pagination' => $pagination));
    }

テンプレートを実装
ページ捲りは別テンプレートで表示するため、ページ捲り用のデータをpという変数でpagination.html.twigに渡してます。

$ vi src/views/page_test.html.twig
<ul>
{% for item in pagination %}
    <li>{{ item }}</li>
{% endfor %}
</ul>
{% include "pagination.html.twig" with { 'p' : pagination.getPaginationData } %}

pagination用のテンプレートを準備

$ vi src/views/pagination.html.twig
{% if p.pageCount > 1 %}
<div class="pagination">
  <ul>
    {% if p.first is defined and p.current != p.first %}
      <li class="prev">
        <a href="/page/{{ p.first }}">&lt;&lt;</a>
      </li>
    {% else %}
      <li class="prev disabled"><a href="/page/{{ p.first}}">&lt;&lt;</a></li>
    {% endif %}
    {% if previous is defined %}
      <li class="prev">
        <a href="/page/{{ p.previous }}">&lt;</a>
      </li>
    {% endif %}

    {% for page in p.pagesInRange %}
    <li {% if page == p.current %}class="active"{% endif %}><a href="/page/{{ page }}"> {{ page }} </a></li>
    {% endfor %}

    {% if p.next is defined %}
      <li class="next">
        <a href="/page/{{ p.next }}">&gt;</a>
      </li>
    {% endif %}

    {% if p.last is defined and p.current != p.last %}
      <li class="next">
        <a href="/page/{{ p.last }}">&gt;&gt;</a>
      </li>
    {% else %}
      <li class="next disabled">
        <a href="/page/{{ p.last }}">&gt;&gt;</a>
      </li>
    {% endif %}
  </ul>
</div>
{% endif %}

これで完了

slimでphpunit使用して結合テスト

今回は、結合テストを行う方法を記す。
goutteはブラウザエミュレートしてくれるphpライブラリです。
今回はgoutteを使ってブラウザのリクエストをエミュレートして正常に表示が行われるかのテストを行います。

goutteのインストール

$ vi composer.json
    "require": {
        "slim/slim":       "2.*",
        "slim/extras":     "2.0.*",
        "twig/twig":       "1.*",
        "pimple/pimple":   "v1.0.2",
        "phpunit/phpunit": "3.7.21",
        "fabpot/goutte":   "v1.0.1"
    },
$ php composer.phar update

結合テストの設定ファイルを作成
tests/integrateディレクトリのテストファイルを結合テストとして読込するよう設定します。

$ vi config/integrate.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap = "bootstrap.php" >
    <testsuites>
        <testsuite name="integrate test">
            <directory>../tests/integrate</directory>
        </testsuite>
    </testsuites>
</phpunit>

結合テストを作成

hoge.comにリクエストを送信して表示内容に「hello hoge」が含まれるかテストします。

$ mkdir -p tests/integrate/
vi tests/integrate/TopTest.php
<?php
namespace Taka512;
use Goutte\Client;

class TopTest extends \PHPUnit_Framework_TestCase
{
    public function testGetName()
    {
        $client = new Client();
        $crawler = $client->request('GET', 'http://hoge.com/');
        $content = $client->getResponse()->getContent();
        $this->assertRegExp('/hello hoge/', $content);
    }
}

テスト実行

結合テストファイルを指定すれば結合テストが行われます。

$ vendor/bin/phpunit -c config/integrate.xml
PHPUnit 3.7.21 by Sebastian Bergmann.

Configuration read from /home/coh2/config/integrate.xml

.

Time: 0 seconds, Memory: 6.25Mb

OK (1 test, 1 assertion)

slimでphpunit使用してユニットテスト

今回はphpunitをインストールした上で、ユニットテストを行う手順を記します。
ユニットテストはメソッド単体だったりの小さい粒度のテストで
今回はsrc/Taka512/Services/NameService.phpのgetNameメソッドをテストします。
サービスクラスは以下のような感じでコンテナに登録しているとします。

$container['app.services.name_service'] = $container->share(function ($c) {
    return new \Taka512\Services\NameService($c);
});

まずアプリケーション側をテストしやすい形に作り替えます。
具体的にはindex.phpで行っていたコンテナ作成処理等をbootstrap.phpに移動します。

web/index.php

<?php
define('ENVIRONMENT', 'prod');
require '../config/bootstrap.php';

$container = createContainer();
$app = $container['app'];
require PROJECT_DIR.'/config/routes.php';
$app->run();

config/bootstrap.php

<?php
define('PROJECT_DIR', dirname(__FILE__) . '/..');
require PROJECT_DIR . '/vendor/autoload.php';

function createContainer()
{
    require PROJECT_DIR . '/config/config.php';
    $container = new \Pimple();
    $container['app'] = new \Slim\Slim($config);

    require PROJECT_DIR . '/config/parameters.php';
    require PROJECT_DIR . '/config/databases.php';
    require PROJECT_DIR . '/config/services.php';

    return $container;
}

phpunitのインストール

composerでインストールするとvendor/binの下にphpunitがインストールされます。

$ vi composer.json
    "require": {
        "slim/slim":       "2.*",
        "slim/extras":     "2.0.*",
        "twig/twig":       "1.*",
        "pimple/pimple":   "v1.0.2",
        "phpunit/phpunit": "3.7.21",
    },
$ php composer.phar update
$ ls vendor/bin/phpunit
vendor/bin/phpunit

テストの設定ファイルを作る

ユニットテスト用の設定ファイルをconfigの下に作成します。
先ほど作成したbootstrapを読み込んで「tests/unit」下のテストを起動する設定となります。

$ vi config/unit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap = "bootstrap.php" >
    <testsuites>
        <testsuite name="unit test">
            <directory>../tests/unit</directory>
        </testsuite>
    </testsuites>
</phpunit>

テスト作成

テストディレクトリを掘ってテストファイルを作成します。

$ mkdir -p test/unit/Services
$ vi tests/unit/Services/NameServiceTest.php
<?php
namespace Taka512\Services;

class NameServiceTest extends \PHPUnit_Framework_TestCase
{
    public function testGetName()
    {
        \Slim\Environment::mock();
        $container = createContainer();
        $name = $container['app.services.name_service']->getName();
        $this->assertEquals($name, 'hoge');
    }
}


テスト実行

設定ファイルを指定してphpunitを実行するとサービスクラスのテストができましたとさ!

$ vendor/bin/phpunit -c config/unit.xml
PHPUnit 3.7.21 by Sebastian Bergmann.

Configuration read from /home/coh2/config/unit.xml

.

Time: 0 seconds, Memory: 5.00Mb

OK (1 test, 1 assertion)

slimでpimpleを使ってデータベースに接続

pimpleを使ってデータベースへの接続を行いデータベースからデータを取ってくるようにします。

データベースにテーブルとデータを追加

MYSQLのhogeデータベースにユーザ「hoge」パスワード「hogepass」で接続できるように設定してます。

create databases hoge;
GRANT ALL PRIVILEGES ON hoge.* TO hoge@localhost IDENTIFIED BY 'hogepass';
flush privileges;
mysql> use hoge
mysql> create table user (name varchar(10));
mysql> insert into user (name)values('taka512');

dbに接続するクラスを作成

PDOを利用してMYSQLに接続するクラスです。

$ mkdir src/Taka512/Db
$ vi src/Taka512/Db/Mysql.php
<?php
namespace Taka512\Db;
class Mysql
{
    protected static $conn = null;
    public static function connect($host, $database, $user, $password)
    {
        if (self::$conn == null) {
            try {
                self::$conn = new \PDO(
                    sprintf('mysql:host=%s;dbname=%s;charset=utf8', $host, $database),
                    $user,
                    $password,
                    array(\PDO::ATTR_EMULATE_PREPARES => false)
                );
            } catch (\PDOException $e) {
                echo 'Connection failed: ' . $e->getMessage();
                die;
            }
        }
        return self::$conn;
    }
}

dbからデータを取得処理を作成

今までgetNameは固定文字列を返してましたがデータベースからデータを取得するように修正

$ vi src/Taka512/Services/NameService.php
class NameService
{
~省略~
    public function getName()
    {
        $name = null;
        try {
            $stmt = $this->di['db_master']->query("SELECT name FROM user");

            while($row = $stmt->fetch(\PDO::FETCH_ASSOC)){
                $name =  $row['name'];
            }
        } catch (\PDOException $e){
            var_dump($e->getMessage());
            die;
        }
        return $name;

index.phpの修正
pimpleにデータベースへの接続処理を追加します。

$ vi web/index.php
~省略~
$container = new Pimple();
$container['app'] = $app;

$container['db_master'] = $container->share(function($c){
       return \Taka512\Db\Mysql::connect(
            'localhost'/*host*/,
            'hoge'/*database*/,
            'hoge'/*id*/,
            'hogepass'/*pass*/);
        });

こんな感じでデータベースへの接続処理を追加できます。