15. 15日目: ウェブサービス

  • この記事は Symfony 1.4 向けのオリジナルの Jobeet Tutorial の一部分です。
Jobeet の上のフィードを追加することで、求職者はリアルタイムで新しい求人を知ることができます。
反対に、ジョブを投稿するときは、最大限可能な限り露出したいと思うでしょう。
もし小さな多くのウェブサイトが連合した場合、よりよい人を見つける機会が増えるでしょう。
つまり、ロングテールの力です。
今日開発する Web サービスによって、アフィリエイトは自分のサイトに最新の投稿されたジョブを公開することができるようになります。

15.1. アフィリエイト

すでにこのチュートリアルの 2 日目で述べたように、アフィリエイトは、現在のアクティブなジョブのリストを取得します。

15.2. フィクスチャー

それではアフィリエイト用に新しいフィクスチャファイルを作成してみましょう。

src/Ibw/JobeetBundle/DataFixtures/ORM/LoadAffiliateData.php

<?php
namespace Ibw\JobeetBundle\DataFixtures\ORM;

use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Ibw\JobeetBundle\Entity\Affiliate;

class LoadAffiliateData extends AbstractFixture implements OrderedFixtureInterface
{
    public function load(ObjectManager $em)
    {
        $affiliate = new Affiliate();

        $affiliate->setUrl('http://sensio-labs.com/');
        $affiliate->setEmail('address1@example.com');
        $affiliate->setToken('sensio-labs');
        $affiliate->setIsActive(true);
        $affiliate->addCategory($em->merge($this->getReference('category-programming')));

        $em->persist($affiliate);

        $affiliate = new Affiliate();

        $affiliate->setUrl('/');
        $affiliate->setEmail('address2@example.org');
        $affiliate->setToken('symfony');
        $affiliate->setIsActive(false);
        $affiliate->addCategory($em->merge($this->getReference('category-programming')), $em->merge($this->getReference('category-design')));

        $em->persist($affiliate);
        $em->flush();

        $this->addReference('affiliate', $affiliate);
    }

    public function getOrder()
    {
        return 3; // This represents the order in which fixtures will be loaded
    }
}

ここで、フィクスチャーファイルで定義されたデータを永続化するために、次のコマンドを実行します。

$ php app/console doctrine:fixtures:load
フィクスチャーファイルでは、トークンは、テストを簡略化するためにハードコードされていますが、実際のユーザーがアカウントに適用されたときには、トークンを生成する必要があります。
アフィリエイトクラスでトークンを生成する関数を作成してみましょう。
お使いの ORM ファイル内、 lifecycleCallbacks セクションに setTokenValue メソッドを追加することで始めます。

src/Ibw/JobeetBundle/Resources/config/doctrine/Affiliate.orm.yml

# ...
    lifecycleCallbacks:
        prePersist: [ setCreatedAtValue, setTokenValue ]

ここで、次のコマンドを実行することで、 setTokenValue メソッドはエンティティファイルの中に生成されます。

$ php app/console doctrine:generate:entities IbwJobeetBundle

それでは、メソッドを変更してみましょう。

src/Ibw/JobeetBundle/Entity/Affiliate.php

public function setTokenValue()
{
  if(!$this->getToken()) {
      $token = sha1($this->getEmail().rand(11111, 99999));
      $this->token = $token;
  }

  return $this;
}

データをリロードします。

$ php app/console doctrine:fixtures:load

15.3. ジョブのウェブサービス

いつものように新しいリソースを作成するときに、最初にルートを定義するのは良い習慣です。

src/Ibw/JobeetBundle/Resources/config/routing.yml

IbwJobeetBundle_api:
    pattern: /api/{token}/jobs.{_format}
    defaults: {_controller: "IbwJobeetBundle:Api:list"}
    requirements:
        _format: xml|json|yaml

いつものように、ルーティングファイルを変更した後はキャッシュをクリアする必要があります。

$ php app/console cache:clear --env=dev
$ php app/console cache:clear --env=prod
次のステップは、同じ動作を共有する API のアクションとテンプレートを作成することです。
ここで ApiController という新しいコントローラファイルを作成してみましょう。

src/Ibw/JobeetBundle/Controller/ApiController.php

<?php
namespace Ibw\JobeetBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Ibw\JobeetBundle\Entity\Affiliate;
use Ibw\JobeetBundle\Entity\Job;
use Ibw\JobeetBundle\Repository\AffiliateRepository;

class ApiController extends Controller
{
    public function listAction(Request $request, $token)
    {
        $em = $this->getDoctrine()->getManager();

        $jobs = array();

        $rep = $em->getRepository('IbwJobeetBundle:Affiliate');
        $affiliate = $rep->getForToken($token);

        if(!$affiliate) {
            throw $this->createNotFoundException('This affiliate account does not exist!');
        }

        $rep = $em->getRepository('IbwJobeetBundle:Job');
        $active_jobs = $rep->getActiveJobs(null, null, null, $affiliate->getId());

        foreach ($active_jobs as $job) {
            $jobs[$this->get('router')->generate('ibw_job_show', array('company' => $job->getCompanySlug(), 'location' => $job->getLocationSlug(), 'id' => $job->getId(), 'position' => $job->getPositionSlug()), true)] = $job->asArray($request->getHost());
        }

        $format = $request->getRequestFormat();
        $jsonData = json_encode($jobs);

        if ($format == "json") {
            $headers = array('Content-Type' => 'application/json');
            $response = new Response($jsonData, 200, $headers);

            return $response;
        }

        return $this->render('IbwJobeetBundle:Api:jobs.' . $format . '.twig', array('jobs' => $jobs));
    }
}
トークンを使用してアフィリエイトユーザーを取得するには、 getForToken() メソッドを作成します。
このメソッドはまたアフィリエイトアカウントがアクティブ化されているかを検証しますので、再度チェックする必要はありません。
今までまだ AffiliateRepository を使用していないので存在していません。
それを作成するには、次のように ORM ファイルを変更し、エンティティを生成する際に使用したコマンドを実行します。

src/Ibw/JobeetBundle/Resources/config/doctrine/Affiliate.orm.yml

Ibw\JobeetBundle\Entity\Affiliate:
    type: entity
    repositoryClass: Ibw\JobeetBundle\Repository\AffiliateRepository
    # ...

一度作成されたら、使用する準備はできています。

src/Ibw/JobeetBundle/Repository/AffiliateRepository.php

<?php
namespace Ibw\JobeetBundle\Repository;

use Doctrine\ORM\EntityRepository;

/**
 * AffiliateRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class AffiliateRepository extends EntityRepository
{
    public function getForToken($token)
    {
        $qb = $this->createQueryBuilder('a')
            ->where('a.is_active = :active')
            ->setParameter('active', 1)
            ->andWhere('a.token = :token')
            ->setParameter('token', $token)
            ->setMaxResults(1)
        ;

        try{
            $affiliate = $qb->getQuery()->getSingleResult();
        } catch(\Doctrine\Orm\NoResultException $e){
            $affiliate = null;
        }

        return $affiliate;
    }
}
トークンによってアフィリエイトを識別した後、アフィリエイトが選択したカテゴリに属するジョブを与えるため、 getActiveJobs() メソッドを使用します。
現在、 JobRepository ファイルを開くと、getActiveJobs() メソッドは、アフィリエイトとのどのような接続も共有していないことがわかります。
そのメソッドを再利用したいので、その中のいくつかの変更を行う必要があります。

src/Ibw/JobeetBundle/Repository/JobRepository.php

// ...

public function getActiveJobs($category_id = null, $max = null, $offset = null, $affiliate_id = null)
{
  $qb = $this->createQueryBuilder('j')
      ->where('j.expires_at > :date')
      ->setParameter('date', date('Y-m-d H:i:s', time()))
      ->andWhere('j.is_activated = :activated')
      ->setParameter('activated', 1)
      ->orderBy('j.expires_at', 'DESC');

  if($max) {
      $qb->setMaxResults($max);
  }

  if($offset) {
      $qb->setFirstResult($offset);
  }

  if($category_id) {
      $qb->andWhere('j.category = :category_id')
          ->setParameter('category_id', $category_id);
  }
  // j.category c, c.affiliate a
  if($affiliate_id) {
      $qb->leftJoin('j.category', 'c')
         ->leftJoin('c.affiliates', 'a')
         ->andWhere('a.id = :affiliate_id')
         ->setParameter('affiliate_id', $affiliate_id)
      ;
  }

  $query = $qb->getQuery();

  return $query->getResult();
}

// ...

ご覧のように、asArray() という関数を使用して、ジョブを配列に移植します。それを定義してみましょう。

src/Ibw/JobeetBundle/Entity/Job.php

public function asArray($host)
{
    return array(
        'category'     => $this->getCategory()->getName(),
        'type'         => $this->getType(),
        'company'      => $this->getCompany(),
        'logo'         => $this->getLogo() ? 'http://' . $host . '/uploads/jobs/' . $this->getLogo() : null,
        'url'          => $this->getUrl(),
        'position'     => $this->getPosition(),
        'location'     => $this->getLocation(),
        'description'  => $this->getDescription(),
        'how_to_apply' => $this->getHowToApply(),
        'expires_at'   => $this->getCreatedAt()->format('Y-m-d H:i:s'),
    );
}

15.4. XML 形式

xml 形式をサポートすることは、テンプレートを作成するのと同じくらい簡単です。

src/Ibw/JobeetBundle/Resources/views/Api/jobs.xml.twig

<?xml version="1.0" encoding="utf-8"?>
<jobs>
{% for url, job in jobs %}
    <job url="{{ url }}">
{% for key,value in job %}
        <{{ key }}>{{ value }}</{{ key }}>
{% endfor %}
    </job>
{% endfor %}
</jobs>

15.5. json 形式

JSON 形式をサポートすることも同様です。

src/Ibw/JobeetBundle/Resources/views/Api/jobs.json.twig

{% for url, job in jobs %}
{% i = 0, count(jobs), ++i %}
[
    "url":"{{ url }}",
{% for key, value in job %} {% j = 0, count(key), ++j %}
    "{{ key }}":"{% if j == count(key)%} {{ json_encode(value) }}, {% else %} {{ json_encode(value) }}
                 {% endif %}"
{% endfor %}]
{% endfor %}

15.6. yaml 形式

src/Ibw/JobeetBundle/Resources/views/Api/jobs.yaml.twig

{% for url,job in jobs %}
    Url: {{ url }}
{% for key, value in job %}
        {{ key }}: {{ value }}
{% endfor %}
{% endfor %}
有効でないトークンを使用して Web サービスを呼び出そうとした場合は、すべての形式の応答として 404 ページを受け取ります。
今までの達成したものを見るため、以下のリンクを参照ください。
または
お好みの形式に応じて、URLの拡張子を変更してください。

15.7. Web サービスのテスト

src/Ibw/JobeetBundle/Tests/Controller/ApiControllerTest.php

<?php
namespace Ibw\JobeetBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\HttpExceptionInterface;

class ApiControllerTest extends WebTestCase
{
    private $em;

    private $application;

    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();

        $this->application = new Application(static::$kernel);

        // drop the database
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());

        // we have to close the connection after dropping the database so we don't get "No database selected" error
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }

        // create the database
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());

        // create schema
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());

        // get the Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();

        // load fixtures
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testList()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/api/sensio-labs/jobs.xml');

        $this->assertEquals('Ibw\JobeetBundle\Controller\ApiController::listAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue($crawler->filter('description')->count() == 32);

        $crawler = $client->request('GET', '/api/sensio-labs87/jobs.xml');

        $this->assertTrue(404 === $client->getResponse()->getStatusCode());

        $crawler = $client->request('GET', '/api/symfony/jobs.xml');

        $this->assertTrue(404 === $client->getResponse()->getStatusCode());

        $crawler = $client->request('GET', '/api/sensio-labs/jobs.json');

        $this->assertEquals('Ibw\JobeetBundle\Controller\ApiController::listAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertRegExp('/"category"\:"Programming"/', $client->getResponse()->getContent());

        $crawler = $client->request('GET', '/api/sensio-labs87/jobs.json');

        $this->assertTrue(404 === $client->getResponse()->getStatusCode());

        $crawler = $client->request('GET', '/api/sensio-labs/jobs.yaml');
        $this->assertRegExp('/category\: Programming/', $client->getResponse()->getContent());

        $this->assertEquals('Ibw\JobeetBundle\Controller\ApiController::listAction', $client->getRequest()->attributes->get('_controller'));

        $crawler = $client->request('GET', '/api/sensio-labs87/jobs.yaml');

        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
    }
}

ApiControllerTest ファイル内では、リクエストフォーマットが正しく受信され、要求されたページが正しく返されることをテストします。

15.8. アフィリエイト申込フォーム

ここで、 Web サービスが使用できる状態になりましたので、アフィリエイト用のアカウント作成フォームを作成してみましょう。
そのためには、 HTML フォームを書き、各フィールドの検証ルールを実装し、データベースに値を保存する処理をし、エラー·メッセージを表示し、エラーの場合はフィールドを再設定します。
まず、 AffiliateController という名前の新しいコントローラのファイルを作成します。

src/Ibw/JobeetBundle/Controller/AffiliateController.php

<?php
namespace Ibw\JobeetBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Ibw\JobeetBundle\Entity\Affiliate;
use Ibw\JobeetBundle\Form\AffiliateType;
use Symfony\Component\HttpFoundation\Request;
use Ibw\JobeetBundle\Entity\Category;

class AffiliateController extends Controller
{
    // Your code goes here
}

その後、レイアウト内のアフィリエイトリンクを変更します。

src/Ibw/JobeetBundle/Resources/views/layout.html.twig

<!-- ... -->
    <li class="last"><a href="{{ path('ibw_affiliate_new') }}">Become an affiliate</a></li>
<!-- ... -->

ここで、上記で修正したリンクのルートと一致するアクションを作成する必要があります。

src/Ibw/JobeetBundle/Controller/AffiliateController.php

<?php
namespace Ibw\JobeetBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Ibw\JobeetBundle\Entity\Affiliate;
use Ibw\JobeetBundle\Form\AffiliateType;
use Symfony\Component\HttpFoundation\Request;
use Ibw\JobeetBundle\Entity\Category;

class AffiliateController extends Controller
{
    public function newAction()
    {
        $entity = new Affiliate();
        $form = $this->createForm(new AffiliateType(), $entity);

        return $this->render('IbwJobeetBundle:Affiliate:affiliate_new.html.twig', array(
            'entity' => $entity,
            'form'   => $form->createView(),
        ));
    }
}

ルートの名前とアクションを設定しましたが、まだルーティング設定をしていません。では、作成してみましょう。

src/Ibw/JobeetBundle/Resources/config/routing/affiliate.yml

ibw_affiliate_new:
    pattern:  /new
    defaults: { _controller: "IbwJobeetBundle:Affiliate:new" }

さらに、ルーティングファイルにこれを追加します。

src/Ibw/JobeetBundle/Resources/config/routing.yml

# ...

IbwJobeetBundle_ibw_affiliate:
    resource: "@IbwJobeetBundle/Resources/config/routing/affiliate.yml"
    prefix:   /affiliate
フォームファイルも作成する必要があります。
しかし、たとえアフィリエイトが多くのフィールドを持っていても、エンドユーザーが編集可能ではならないものもあるため、すべては表示はしません。

src/Ibw/JobeetBundle/Form/AffiliateType.php

<?php
namespace Ibw\JobeetBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Ibw\JobeetBundle\Entity\Affiliate;
use Ibw\JobeetBundle\Entity\Category;

class AffiliateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('url')
            ->add('email')
            ->add('categories', null, array('expanded'=>true))
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Ibw\JobeetBundle\Entity\Affiliate',
        ));
    }

    public function getName()
    {
        return 'affiliate';
    }
}
ここで、フォームに送信されたデータが設定された後、アフィリエイトオブジェクトが有効であるかどうかを判断する必要があります。
これを行うには、バリデーションファイルに次のコードを追加します。

src/Ibw/JobeetBundle/Resources/config/validation.yml

# ...

Ibw\JobeetBundle\Entity\Affiliate:
    constraints:
        - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity: email
    properties:
        url:
            - Url: ~
        email:
            - NotBlank: ~
            - Email: ~
検証の設定では、 UniqueEntity という新しい検証制約を使用しました。
これは Doctrine のエンティティ内の特定のフィールド(または複数のフィールド)がユニークであることを検証します。
これは、例えば、新しいユーザーを登録する際に既にシステム上存在するメールアドレスを使用することを防止するために使われます。
検証制約を適用した後にキャッシュをクリアすることを忘れないでください!
最後に、フォームのビューも作成してみましょう。

src/Ibw/JobeetBundle/Resources/views/Affiliate/affiliate_new.html.twig

{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% form_theme form _self %}

{% block form_errors %}
{% spaceless %}
    {% if errors|length > 0 %}
        <ul class="error_list">
            {% for error in errors %}
                <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li>
            {% endfor %}
        </ul>
    {% endif %}
{% endspaceless %}
{% endblock form_errors %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}

{% block content %}
    <h1>Become an affiliate</h1>
        <form action="{{ path('ibw_affiliate_create') }}" method="post" {{ form_enctype(form) }}>
            <table id="job_form">
                <tfoot>
                    <tr>
                        <td colspan="2">
                            <input type="submit" value="Submit" />
                        </td>
                    </tr>
                </tfoot>
                <tbody>
                    <tr>
                        <th>{{ form_label(form.url) }}</th>
                        <td>
                            {{ form_errors(form.url) }}
                            {{ form_widget(form.url) }}
                        </td>
                    </tr>
                    <tr>
                        <th>{{ form_label(form.email) }}</th>
                        <td>
                            {{ form_errors(form.email) }}
                            {{ form_widget(form.email) }}
                        </td>
                    </tr>
                    <tr>
                        <th>{{ form_label(form.categories) }}</th>
                        <td>
                            {{ form_errors(form.categories) }}
                            {{ form_widget(form.categories) }}
                        </td>
                    </tr>
                </tbody>
            </table>
        {{ form_end(form) }}
{% endblock %}
ユーザーがフォームを送信した際に、有効な場合は、フォームデータをデータベースに永続化しなくてはなりません。
AffiliateController に新しい create アクションを追加します。

src/Ibw/JobeetBundle/Controller/AffiliateController.php

class AffiliateController extends Controller
{
    // ...

    public function createAction(Request $request)
    {
        $affiliate = new Affiliate();
        $form = $this->createForm(new AffiliateType(), $affiliate);
        $form->bind($request);
        $em = $this->getDoctrine()->getManager();

        if ($form->isValid()) {

            $formData = $request->get('affiliate');
            $affiliate->setUrl($formData['url']);
            $affiliate->setEmail($formData['email']);
            $affiliate->setIsActive(false);

            $em->persist($affiliate);
            $em->flush();

            return $this->redirect($this->generateUrl('ibw_affiliate_wait'));
        }

        return $this->render('IbwJobeetBundle:Affiliate:affiliate_new.html.twig', array(
            'entity' => $affiliate,
            'form'   => $form->createView(),
        ));
    }
}

create アクションを先に作ったので、次にルートを定義します。

src/Ibw/JobeetBundle/Resources/config/routing/affiliate.yml

# ...

ibw_affiliate_create:
    pattern: /create
    defaults: { _controller: "IbwJobeetBundle:Affiliate:create" }
    requirements: { _method: post }
アフィリエイト登録された後、待ちページにリダイレクトされます。
それでは、そのアクションを定義し、ビューも作成してみましょう。

src/Ibw/JobeetBundle/Controller/AffiliateController.php

class AffiliateController extends Controller
{
    // ...

    public function waitAction()
    {
        return $this->render('IbwJobeetBundle:Affiliate:wait.html.twig');
    }
}

src/Ibw/JobeetBundle/Resources/views/Affiliate/wait.html.twig

{% extends "IbwJobeetBundle::layout.html.twig" %}

{% block content %}
    <div class="content">
        <h1>Your affiliate account has been created</h1>
        <div style="padding: 20px">
            Thank you!
            You will receive an email with your affiliate token
            as soon as your account will be activated.
        </div>
    </div>
{% endblock %}

ここで、ルートを追加します。

src/Ibw/JobeetBundle/Resources/config/routing/affiliate.yml

# ...

ibw_affiliate_wait:
    pattern: /wait
    defaults: { _controller: "IbwJobeetBundle:Affiliate:wait" }
ルートを定義したら、キャッシュをクリアする必要があります。
これで、ホームページでアフィリエイトリンクをクリックした場合、アフィリエイトフォームのページに転送されます。

15.9. テスト

最後のステップは新しい機能のために、いくつかの機能テストを書くことです。

src/Ibw/JobeetBundle/Tests/Controller/AffiliateControllerTest.php

<?php
namespace Ibw\JobeetBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
use Symfony\Component\DomCrawler\Crawler;

class AffiliateControllerTest extends WebTestCase
{
    private $em;
    private $application;

    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();

        $this->application = new Application(static::$kernel);

        // drop the database
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());

        // we have to close the connection after dropping the database so we don't get "No database selected" error
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }

        // create the database
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());

        // create schema
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());

        // get the Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();

        // load fixtures
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testAffiliateForm()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/affiliate/new');

        $this->assertEquals('Ibw\JobeetBundle\Controller\AffiliateController::newAction', $client->getRequest()->attributes->get('_controller'));

        $form = $crawler->selectButton('Submit')->form(array(
            'affiliate[url]' => 'http://sensio-labs.com/',
            'affiliate[email]' => 'jobeet@example.com'
        ));

        $client->submit($form);
        $this->assertEquals('Ibw\JobeetBundle\Controller\AffiliateController::createAction', $client->getRequest()->attributes->get('_controller'));

        $kernel = static::createKernel();
        $kernel->boot();
        $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

        $query = $em->createQuery('SELECT count(a.email) FROM IbwJobeetBundle:Affiliate a WHERE a.email = :email');
        $query->setParameter('email', 'jobeet@example.com');
        $this->assertEquals(1, $query->getSingleScalarResult());

        $crawler = $client->request('GET', '/affiliate/new');
        $form = $crawler->selectButton('Submit')->form(array(
            'affiliate[email]'        => 'not.an.email',
        ));
        $crawler = $client->submit($form);

        // check if we have 1 errors
        $this->assertTrue($crawler->filter('.error_list')->count() == 1);
        // check if we have error on affiliate_email field
        $this->assertTrue($crawler->filter('#affiliate_email')->siblings()->first()->filter('.error_list')->count() == 1);
    }

    public function testCreate()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/affiliate/new');
        $form = $crawler->selectButton('Submit')->form(array(
            'affiliate[url]' => 'http://sensio-labs.com/',
            'affiliate[email]' => 'address@example.com'
        ));

        $client->submit($form);
        $client->followRedirect();

        $this->assertEquals('Ibw\JobeetBundle\Controller\AffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));

        return $client;
    }

    public function testWait()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/affiliate/wait');

        $this->assertEquals('Ibw\JobeetBundle\Controller\AffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));
    }
}

15.10. アフィリエイト管理画面

管理画面を、 SonataAdminBundle を使用して動作させます。
以前にも言ったように、アフィリエイトの登録の後、管理者がアカウントをアクティブ化するのを待つ必要があります。
そのため、管理者がアフィリエイトのページにアクセスしますと、作業の補助のため、未アクティブなアカウントを表示します。
まず第一に、 新しいアフィリエイトサービスを services.yml ファイル内に宣言する必要があります。

src/Ibw/JobeetBundle/Resources/config/services.yml

# ...
ibw.jobeet.admin.affiliate:
  class: Ibw\JobeetBundle\Admin\AffiliateAdmin
  tags:
      - { name: sonata.admin, manager_type: orm, group: jobeet, label: Affiliates }
  arguments:
      - ~
      - Ibw\JobeetBundle\Entity\Affiliate
      - 'IbwJobeetBundle:AffiliateAdmin'

その後、 Admin ファイルを作成します。

src/Ibw/JobeetBundle/Admin/AffiliateAdmin.php

<?php
namespace Ibw\JobeetBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Validator\ErrorElement;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use Ibw\JobeetBundle\Entity\Affiliate;

class AffiliateAdmin extends Admin
{
    protected $datagridValues = array(
        '_sort_order' => 'ASC',
        '_sort_by' => 'is_active'
    );

    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('email')
            ->add('url')
        ;
    }

    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('email')
            ->add('is_active');
    }

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->add('is_active')
            ->addIdentifier('email')
            ->add('url')
            ->add('created_at')
            ->add('token')
        ;
    }
}
管理者を助けるために、未アクティブなアカウントのみ表示したいです。
これは is_active フィルタを false に 設定することで行うことができます。

src/Ibw/JobeetBundle/Admin/AffiliateAdmin.php

// ...
protected $datagridValues = array(
  '_sort_order' => 'ASC',
  '_sort_by' => 'is_active',
  'is_active' => array('value' => 2) // The value 2 represents that the displayed affiliate accounts are not activated yet
);

// ...

ここで、 AffiliateAdmin コントローラファイルを作成します。

src/Ibw/JobeetBundle/Controller/AffiliateAdminController.php

<?php
namespace Ibw\JobeetBundle\Controller;

use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery as ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;

class AffiliateAdminController extends Controller
{
    // Your code goes here
}

それでは activatedeactivate のバッチアクションを作成しましょう。

src/Ibw/JobeetBundle/Controller/AffiliateAdminController.php

namespace Ibw\JobeetBundle\Controller;

use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery as ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;

class AffiliateAdminController extends Controller
{
    public function batchActionActivate(ProxyQueryInterface $selectedModelQuery)
    {
        if($this->admin->isGranted('EDIT') === false || $this->admin->isGranted('DELETE') === false) {
            throw new AccessDeniedException();
        }

        $request = $this->get('request');
        $modelManager = $this->admin->getModelManager();

        $selectedModels = $selectedModelQuery->execute();

        try {
            foreach($selectedModels as $selectedModel) {
                $selectedModel->activate();
                $modelManager->update($selectedModel);
            }
        } catch(\Exception $e) {
            $this->get('session')->getFlashBag()->add('sonata_flash_error', $e->getMessage());

            return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
        }

        $this->get('session')->getFlashBag()->add('sonata_flash_success',  sprintf('The selected accounts have been activated'));

        return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
    }

    public function batchActionDeactivate(ProxyQueryInterface $selectedModelQuery)
    {
        if($this->admin->isGranted('EDIT') === false || $this->admin->isGranted('DELETE') === false) {
            throw new AccessDeniedException();
        }

        $request = $this->get('request');
        $modelManager = $this->admin->getModelManager();

        $selectedModels = $selectedModelQuery->execute();

        try {
            foreach($selectedModels as $selectedModel) {
                $selectedModel->deactivate();
                $modelManager->update($selectedModel);
            }
        } catch(\Exception $e) {
            $this->get('session')->getFlashBag()->add('sonata_flash_error', $e->getMessage());

            return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
        }

        $this->get('session')->getFlashBag()->add('sonata_flash_success',  sprintf('The selected accounts have been deactivated'));

        return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
    }
}

新しいバッチアクションを機能させるには、 Admin クラスの getBatchActions にそれらを追加する必要があります。

src/Ibw/JobeetBundle/Admin/AffiliateAdmin.php

class AffiliateAdmin extends Admin
{
    // ...

    public function getBatchActions()
    {
        $actions = parent::getBatchActions();

        if($this->hasRoute('edit') && $this->isGranted('EDIT') && $this->hasRoute('delete') && $this->isGranted('DELETE')) {
            $actions['activate'] = array(
                'label'            => 'Activate',
                'ask_confirmation' => true
            );

            $actions['deactivate'] = array(
                'label'            => 'Deactivate',
                'ask_confirmation' => true
            );
        }

        return $actions;
    }
}

これが機能するために、エンティティファイルに activatedeactivate の 2 つのメソッドを追加する必要があります。

src/Ibw/JobeetBundle/Entity/Affiliate.php

// ...

public function activate()
{
  if(!$this->getIsActive()) {
      $this->setIsActive(true);
  }

  return $this->is_active;
}

public function deactivate()
{
  if($this->getIsActive()) {
      $this->setIsActive(false);
  }

  return $this->is_active;
}
それでは、activatedeactivate の項目ごと二つの個別のアクションを作成してみましょう。
第一に、ルートを作成します。
これが、Admin クラスで、 configureRoutes メソッドを継承した理由です。

src/Ibw/JobeetBundle/Admin/AffiliateAdmin.php

use Sonata\AdminBundle\Route\RouteCollection;

class AffiliateAdmin extends Admin
{
    // ...

    protected function configureRoutes(RouteCollection $collection) {
        parent::configureRoutes($collection);

        $collection->add('activate',
            $this->getRouterIdParameter().'/activate')
        ;

        $collection->add('deactivate',
            $this->getRouterIdParameter().'/deactivate')
        ;
    }
}

それでは AdminController でアクションを実装してみましょう。

src/Ibw/JobeetBundle/Controller/AffiliateAdminController.php

class AffiliateAdminController extends Controller
{
    // ...

    public function activateAction($id)
    {
        if($this->admin->isGranted('EDIT') === false) {
            throw new AccessDeniedException();
        }

        $em = $this->getDoctrine()->getManager();
        $affiliate = $em->getRepository('IbwJobeetBundle:Affiliate')->findOneById($id);

        try {
            $affiliate->setIsActive(true);
            $em->flush();
        } catch(\Exception $e) {
            $this->get('session')->getFlashBag()->add('sonata_flash_error', $e->getMessage());

            return new RedirectResponse($this->admin->generateUrl('list', $this->admin->getFilterParameters()));
        }

        return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));

    }

    public function deactivateAction($id)
    {
        if($this->admin->isGranted('EDIT') === false) {
            throw new AccessDeniedException();
        }

        $em = $this->getDoctrine()->getManager();
        $affiliate = $em->getRepository('IbwJobeetBundle:Affiliate')->findOneById($id);

        try {
            $affiliate->setIsActive(false);
            $em->flush();
        } catch(\Exception $e) {
            $this->get('session')->getFlashBag()->add('sonata_flash_error', $e->getMessage());

            return new RedirectResponse($this->admin->generateUrl('list', $this->admin->getFilterParameters()));
        }

        return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
    }
}

ここで、新たにアクションボタンを追加するためのテンプレートを作成します。

src/Ibw/JobeetBundle/Resources/views/AffiliateAdmin/list__action_activate.html.twig

{% if admin.isGranted('EDIT', object) and admin.hasRoute('activate') %}
    <a href="{{ admin.generateObjectUrl('activate', object) }}" class="btn edit_link" title="{{ 'action_activate'|trans({}, 'SonataAdminBundle') }}">
        <i class="icon-edit"></i>
        {{ 'activate'|trans({}, 'SonataAdminBundle') }}
    </a>
{% endif %}

src/Ibw/JobeetBundle/Resources/views/AffiliateAdmin/list__action_deactivate.html.twig

{% if admin.isGranted('EDIT', object) and admin.hasRoute('deactivate') %}
    <a href="{{ admin.generateObjectUrl('deactivate', object) }}" class="btn edit_link" title="{{ 'action_deactivate'|trans({}, 'SonataAdminBundle') }}">
        <i class="icon-edit"></i>
        {{ 'deactivate'|trans({}, 'SonataAdminBundle') }}
    </a>
{% endif %}

各アカウントが個別にページに表示されるように、 Admin ファイルの中で、 configureListFields メソッドに新しいアクションとボタンを追加します。

src/Ibw/JobeetBundle/Admin/AffiliateAdmin.php

class AffiliateAdmin extends Admin
{
    // ...

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->add('is_active')
            ->addIdentifier('email')
            ->add('url')
            ->add('created_at')
            ->add('token')
            ->add('_action', 'actions', array( 'actions' => array('activate' => array('template' => 'IbwJobeetBundle:AffiliateAdmin:list__action_activate.html.twig'),
                'deactivate' => array('template' => 'IbwJobeetBundle:AffiliateAdmin:list__action_deactivate.html.twig'))))
        ;
    }
    /// ...
}
ここで、キャッシュをクリアし、試してみてください!
今日はこれですべてです!明日は、アカウントがアクティブ化されたときにアフィリエイトが受け取るメールの設定をします。

See also

Symfony2日本語ドキュメント

豊富な日本語ドキュメントがありますので合わせて読み進めてみましょう。

Note

Creative Commons License

このチュートリアルは、クリエイティブ・コモンズ・ライセンス 表示 - 継承 3.0 非移植 (CC BY-SA 3.0) のもとでライセンスされています。 翻訳の元にしたオリジナルはこちらです。 Symfony2 Jobeet http://www.intelligentbee.com/blog/tag/symfony2-jobeet/.