Symfony 2 Joboard : API для партнёров

В дополнение к новостным лентам, соискатели могут получать информацию о новых вакансиях в режиме реального времени.

С другой стороны, работодатели тоже хотят, чтобы как можно больше людей узнали о новой вакансии. Если вы публикуете свои вакансии на нескольких сайтах, то шанс найти хорошего сотрудника заметно повышается. Cайты партнеры смогут размещать свежие вакансии у себя. Сегодня именно этот функционал мы и попробуем реализовать.

Сайты партнеры

Как мы уже говорили, сайты партнеры должны получать список действующих вакансий.

Фикстуры

Создадим файл фикстур для партнеров:

<?php
# src/App/JoboardBundle/DataFixtures/ORM/LoadAffiliateData.php
namespace App\JoboardBundle\DataFixtures\ORM;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use App\JoboardBundle\Entity\Affiliate;
class LoadAffiliateData extends AbstractFixture implements OrderedFixtureInterface
{
    public function load(ObjectManager $em)
    {
        $affiliate = new Affiliate();
        $affiliate->setUrl('http://example.com/');
        $affiliate->setEmail('[email protected]');
        $affiliate->setToken('example');
        $affiliate->setIsActive(true);
        $affiliate->addCategory($em->merge($this->getReference('category-programming')));
        $em->persist($affiliate);
        $affiliate = new Affiliate();
        $affiliate->setUrl('/');
        $affiliate->setEmail('[email protected]');
        $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; // Порядок в котором будут загружены фикстуры
    }
}

Теперь загрузим эти данные в базу данных:

php app/console doctrine:fixtures:load

В файле фикстур мы записали захардкодили токен, но когда вы создаете аккаунт для нового пользователя, следуют генерировать уникальный токен. Создадим для этого функцию в модели Affiliate. Сначала добавьте метод setTokenValue к lifecycleCallbacks в Affiliate.orm.yml:

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

Теперь сгенерируем метод setTokenValue с помощью следующей команды:

php app/console doctrine:generate:entities AppJoboardBundle

Изменим этот метод в модели Affiliate:

<?php
# src/App/JoboardBundle/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

Веб служба вакансий

Как всегда, после создания нового ресурса следует добавить соответствующий ему маршрут:

# src/App/JoboardBundle/Resources/config/routing.yml
# ...
AppJoboardBundle_api:
   pattern: /api/{token}/jobs.{_format}
   defaults: {_controller: "AppJoboardBundle:Api:list"}
   requirements:
       _format: xml|json|yaml

А замет очистить кеш:

php app/console cache:clear --env=dev
php app/console cache:clear --env=prod

Затем создадим необходимые методы и шаблоны для api. Лучше для этого создадим новый контроллер:

<?php
# src/App/JoboardBundle/Controller/ApiController.php
namespace App\JoboardBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use App\JoboardBundle\Entity\Affiliate;
use App\JoboardBundle\Entity\Job;
use App\JoboardBundle\Repository\AffiliateRepository;
class ApiController extends Controller
{
    public function listAction(Request $request, $token)
    {
        $em   = $this->getDoctrine()->getManager();
        $jobs = [];
        $rep       = $em->getRepository('AppJoboardBundle:Affiliate');
        $affiliate = $rep->getForToken($token);
        if (!$affiliate) {
            throw $this->createNotFoundException('Такого партнёра не существует!');
        }
        $rep        = $em->getRepository('AppJoboardBundle:Job');
        $activeJobs = $rep->getActiveJobs(null, null, null, $affiliate->getId());
        foreach ($activeJobs as $job) {
            $jobs[$this->generateUrl('app_job_show', [
                '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  = ['Content-Type' => 'application/json'];
            $response = new Response($jsonData, 200, $headers);
            return $response;
        }
        return $this->render('AppJoboardBundle:Api:jobs.' . $format . '.twig', ['jobs' => $jobs]);
    }
}

Для получения партнера при помощи токена нам потребуется новый метод getForToken(). Также он будет проверять активен ли данный аккаунт. До настоящего времени мы не использовали репозиторий для класса Affiliate. Для его создания требуется изменить ORM файл, а затем выполнить команду для генерации сущностей.

# src/App/JoboardBundle/Resources/config/doctrine/Affiliate.orm.yml
App\JoboardBundle\Entity\Affiliate:
   type: entity
   repositoryClass: App\JoboardBundle\Repository\AffiliateRepository
   # ...
php app/console doctrine:generate:entities AppJoboardBundle

После создания его можно начинать использовать:

<?php
# src/App/JoboardBundle/Repository/AffiliateRepository.php
namespace App\JoboardBundle\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() мы выдаем ему список действующих вакансий в определенной категории. Если вы обратите внимание, то заметите что на данный момент метод getActiveJobs() не имеет общего соединения с партнерами. Так как мы планируем использовать этот метод в нескольких участках своего кода, нам необходимо внести в него некоторые изменения:

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
// ...
    public function getActiveJobs($categoryId = null, $max = null, $offset = null, $affiliateId = 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 ($categoryId) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        if ($affiliateId) {
            $qb->leftJoin('j.category', 'c')
                ->leftJoin('c.affiliates', 'a')
                ->andWhere('a.id = :affiliate_id')
                ->setParameter('affiliate_id', $affiliateId)
            ;
        }
        $query = $qb->getQuery();
        return $query->getResult();
    }
// ...

Как вы видите, мы создали массив вакансий при помощи функции asArray(). Давайте напишем её:

<?php
# src/App/JoboardBundle/Entity/Job.php
public function asArray($host)
{
    return [
        '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')
    ];
}

XML формат

Для поддержки формата XML достаточно создать шаблон src/App/JoboardBundle/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>

JSON формат

Тоже самое относится и к json, src/App/JoboardBundle/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 %}

YAML формат

{% for url,job in jobs %}
Url: {{ url }}
{% for key, value in job %}
{{ key }}: {{ value }}
{% endfor %}
{% endfor %}

Если вы обратитесь к службе с некорректным токеном, то в ответ получите ошибку 404. По следующим ссылкам вы можете проверить что у нас получилось на данный момент.

http://joboard.local/app_dev.php/api/example/jobs.xml http://joboard.local/app_dev.php/api/example/jobs.jsonhttp://joboard.local/app_dev.php/api/example/jobs.yaml

Тесты веб служб

<?php
# src/App/JoboardBundle/Tests/Controller/ApiControllerTest.php 
namespace App\JoboardBundle\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\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);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/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/example/jobs.xml');
        $this->assertEquals('App\JoboardBundle\Controller\ApiController::listAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue($crawler->filter('description')->count() == 32);
        $client->request('GET', '/api/example111/jobs.xml');
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
        $client->request('GET', '/api/example87/jobs.json');
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
        $client->request('GET', '/api/example87/jobs.yaml');
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
        $client->request('GET', '/api/example/jobs.yaml');
        $this->assertRegExp('/category\: Программирование/', $client->getResponse()->getContent());
        $this->assertEquals('App\JoboardBundle\Controller\ApiController::listAction', $client->getRequest()->attributes->get('_controller'));
    }
}

Запустим тест

phpunit -c app src/App/JoboardBundle/Tests/Controller/ApiControllerTest.php

Внутри файла ApiControllerTest мы проверяем, что форматы обрабываются соответствующим образом.

Форма заявления для партнера

Наша служба полностью готова, теперь создадим форму для регистрации новых партнеров. Для этого надо написать саму форму HTML, указать правила проверки для каждого поля, обработать введенные данные, сохранить их в базе данных, отобразить ошибки и восстановить значения полей при возникновении ошибок. Сначала создадим новый контроллер AffiliateController:

<?php
# src/App/JoboardBundle/Controller/AffiliateController.php
namespace App\JoboardBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use App\JoboardBundle\Entity\Affiliate;
use App\JoboardBundle\Form\AffiliateType;
use Symfony\Component\HttpFoundation\Request;
use App\JoboardBundle\Entity\Category;
class AffiliateController extends Controller
{
    // Здесь будет ваш код
}

Изменим ссылку для партнеров в футере базового шаблона:

<!-- ... -->
   <a href="{{ path('app_affiliate_new') }}">Партнёрам</a>
<!-- ... -->

Теперь создадим действие для ссылки, которе мы только что включили в наш шаблон:

<?php
namespace App\JoboardBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use App\JoboardBundle\Entity\Affiliate;
use App\JoboardBundle\Form\AffiliateType;
use Symfony\Component\HttpFoundation\Request;
use App\JoboardBundle\Entity\Category;
class AffiliateController extends Controller
{
    public function newAction()
    {
        $entity = new Affiliate();
        $form   = $this->createForm(new AffiliateType(), $entity);
        return $this->render('AppJoboardBundle:Affiliate:affiliate_new.html.twig', [
            'entity' => $entity,
            'form'   => $form->createView(),
        ]);
    }
}

У нас есть имя маршрута, есть действие, но нет самого маршрута. Исправим это, добавьте следующее в свой файл маршрутизации:

# src/App/JoboardBundle/Resources/config/routing.yml
# ...
AppJoboardBundle_app_affiliate:
   resource: "@AppJoboardBundle/Resources/config/routing/affiliate.yml"
   prefix:   /affiliate

После этого создайте файл src/App/JoboardBundle/Resources/config/routing/affiliate.yml и добавьте маршрут:

# src/App/JoboardBundle/Resources/config/routing/affiliate.yml
app_affiliate_new:
   pattern:  /new
   defaults: { _controller: "AppJoboardBundle:Affiliate:new" }

Также необходимо создать файл формы. Мы не будем показывать все поля конечному пользователю, так как он не должен иметь возможность их редактировать. Вот содержание файла формы Affiliate:

<?php
namespace App\JoboardBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use App\JoboardBundle\Entity\Affiliate;
use App\JoboardBundle\Entity\Category;
class AffiliateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('url')
            ->add('email')
            ->add('categories', null, ['expanded' => true])
        ;
    }
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'App\JoboardBundle\Entity\Affiliate',
        ]);
    }
    public function getName()
    {
        return 'affiliate';
    }
}

Теперь необходимо определить корректно ли были заполнены все поля. Для этого добавьте следующий код в файл validation.yml:

# src/App/JoboardBundle/Resources/config/validation.yml
# ...
App\JoboardBundle\Entity\Affiliate:
   constraints:
       - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity: email
   properties:
       url:
           - Url: ~
       email:
           - NotBlank: ~
           - Email: ~

Мы создали новое правило под названием UniqueEntity. Оно проверяет чтобы одно или несколько полей в таблице были уникальны. Такой подход встречается довольно часто, например, чтобы ограничить количество пользователей с одним email адресом.

Не забудьте очистить кеш после обновления правил проверки! Наконец, создадим представление для формы src/App/JoboardBundle/Resources/views/Affiliate/affiliate_new.html.twig:

{% extends 'base.html.twig' %}
{% set form_themes = _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/appjoboard/css/job.css') }}" type="text/css" media="all" />
{% endblock %}
{% block content %}
    <h1>Стать партнёром</h1>
    <form action="{{ path('app_affiliate_create') }}" method="post" {{ form_enctype(form) }}>
        <table id="affiliate-form">
            <tfoot>
            <tr>
                <td colspan="2">
                    <input type="submit" value="Submit" />
                </td>
            </tr>
            </tfoot>
            <tbody>
            <tr>
                <th>{{ form_label(form.url) }}</th>
                <td>
                    {{ form_widget(form.url) }}
                    {{ form_errors(form.url) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.email) }}</th>
                <td>
                    {{ form_widget(form.email) }}
                    {{ form_errors(form.email) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.categories) }}</th>
                <td>
                    {{ form_widget(form.categories) }}
                    {{ form_errors(form.categories) }}
                </td>
            </tr>
            </tbody>
        </table>
    </form>
{% endblock %}

После отправки формы, необходимо проверить полученные данные и внести их в базу данных. Создайте новый метод createAction в Affiliate контроллере:

<?php
# src/App/JoboardBundle/Controller/AffiliateController.php
class AffiliateController extends Controller
{
    // ...    
    public function createAction(Request $request)
    {
        $affiliate = new Affiliate();
        $form = $this->createForm(new AffiliateType(), $affiliate);
        $form->handleRequest($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('app_affiliate_wait'));
        }
        return $this->render('AppJoboardBundle:Affiliate:affiliate_new.html.twig', [
            'entity' => $affiliate,
            'form'   => $form->createView(),
        ]);
   }
}

При отправке формы вызывается метод createAction, поэтому для него необходимо создать маршрут:

# src/App/JoboardBundle/Resources/config/routing/affiliate.yml
# ...
app_affiliate_create:
   pattern: /create
   defaults: { _controller: "AppJoboardBundle:Affiliate:create" }
   requirements: { _method: post }

После регистрации партнер перенаправляется на страницу ожидания. Создадим соответствующие метод и вид:

<?php
class AffiliateController extends Controller
{
   // ...
   public function waitAction()
   {
       return $this->render('AppJoboardBundle:Affiliate:wait.html.twig');
   }
}

Шаблон src/App/JoboardBundle/Resources/views/Affiliate/wait.html.twig:

{% extends "base.html.twig" %}
{% block content %}
    <div class="content">
        <h1>Ваш партнёрский аккаунт был успешно создан</h1>
        <div style="padding: 20px">
            Спасибо!<br>
            Мы выслали вам email письмо для активации аккаунта.
        </div>
    </div>
{% endblock %}

Теперь создадим маршрут:

# src/App/JoboardBundle/Resources/config/routing/affiliate.yml
# ...
app_affiliate_wait:
   pattern: /wait
   defaults: { _controller: "AppJoboardBundle:Affiliate:wait" }

После создания новых маршрутов следует очистить кеш. Теперь если вы перейдете по партнерской ссылке на домашней странице, вы будете перенаправлены на форму регистрации партнера.

Тесты

Последнее что мы сделаем, это создадим несколько функциональных тестов для проверки нового функционала.

<?php
# src/App/JoboardBundle/Tests/Controller/AffilateControllerTest.php
namespace App\JoboardBundle\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;
class AffiliateControllerTest extends WebTestCase
{
    private $em;
    private $application;
    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $this->application = new Application(static::$kernel);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/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('App\JoboardBundle\Controller\AffiliateController::newAction', $client->getRequest()->attributes->get('_controller'));
        $form = $crawler->selectButton('Submit')->form([
            'affiliate[url]'   => 'http://example.com/',
            'affiliate[email]' => '[email protected]'
        ]);
        $client->submit($form);
        $this->assertEquals('App\JoboardBundle\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 AppJoboardBundle:Affiliate a WHERE a.email = :email');
        $query->setParameter('email', '[email protected]');
        $this->assertEquals(1, $query->getSingleScalarResult());
        $crawler = $client->request('GET', '/affiliate/new');
        $form = $crawler->selectButton('Submit')->form([
            'affiliate[email]' => 'not.an.email',
        ]);
        $crawler = $client->submit($form);
        // проверяем если одна ошибка 1 errors
        $this->assertTrue($crawler->filter('ul li')->count() == 1);
        // проверем если выведена ошибка о неверной электронной почте
        $this->assertTrue($crawler->filter('#affiliate_email')->siblings()->first()->filter('ul li')->count() == 1);
    }
    public function testCreate()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/affiliate/new');
        $form = $crawler->selectButton('Submit')->form(array(
            'affiliate[url]'   => 'http://example.com/',
            'affiliate[email]' => '[email protected]'
        ));
        $client->submit($form);
        $client->followRedirect();
        $this->assertEquals('App\JoboardBundle\Controller\AffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));
        return $client;
    }
    public function testWait()
    {
        $client = static::createClient();
        $client->request('GET', '/affiliate/wait');
        $this->assertEquals('App\JoboardBundle\Controller\AffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));
    }
}

Администраторская часть для партнеров

Для создания администраторской части мы воспользуемся SonataAdminBundle. Как мы уже говорили, после регистрации, партнеру необходимо дождаться одобрения администратора. Таким образом администратор должен видеть на своей домашней странице только неактивированные аккаунты.

Сначала нужно определить новую службу в файле services.yml:

# src/App/JoboardBundle/Resources/config/services.yml
# ...
   app.joboard.admin.affiliate:
       class: App\JoboardBundle\Admin\AffiliateAdmin
       tags:
           - { name: sonata.admin, manager_type: orm, group: joboard, label: "Партнёры" }
       arguments:
           - ~
           - App\JoboardBundle\Entity\Affiliate
           - AppJoboardBundle:AffiliateAdmin

Теперь создадим файл Admin:

<?php
# src/App/JoboardBundle/Admin/AffiliateAdmin.php
namespace App\JoboardBundle\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 App\JoboardBundle\Entity\Affiliate;
class AffiliateAdmin extends Admin
{
    protected $datagridValues = [
        '_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 в 2:

<?php
// ...
    protected $datagridValues = [
        '_sort_order' => 'ASC',
        '_sort_by'    => 'is_active',
        'is_active'   => ['value' => 2] // 2 показывает только неактивированные записи
    ];
// ...

Теперь создайте файл AffiliateAdminController:

<?php
# src/App/JoboardBundle/Controller/AffiliateAdminController.php
namespace App\JoboardBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery as ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
class AffiliateAdminController extends Controller
{
    // Тут будет ваш код
}

Создадим групповые действия для активации и деактивации:

<?php
# src/App/JoboardBundle/Controller/AffiliateAdminController.php
namespace App\JoboardBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery as ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class AffiliateAdminController extends Controller
{
    public function batchActionActivate(ProxyQueryInterface $selectedModelQuery)
    {
        if($this->admin->isGranted('EDIT') === false || $this->admin->isGranted('DELETE') === false) {
            throw new AccessDeniedException();
        }
        $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('Выбранные аккаунты были активированы'));
        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();
        }
        $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('Выбранные аккаунты были деактивированы'));
        return new RedirectResponse($this->admin->generateUrl('list', $this->admin->getFilterParameters()));
    }
}

Чтобы активировать новые действия, их необходимо добавить в метод getBatchActions в классе Admin:

<?php
# src/App/JoboardBundle/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'] = [
                'label'            => 'Активировать',
                'ask_confirmation' => true
            ];
            $actions['deactivate'] = [
                'label'            => 'Деактивировать',
                'ask_confirmation' => true
            ];
        }
        return $actions;
    }
}

Теперь, необходимо добавить эти два метода в класс сущности Affiliate:

<?php
# src/App/JoboardBundle/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;
   }

Теперь создадим два отдельных действия: активация и деактивация, для каждого аккаунта. Сначала, создадим маршруты. Именно поэтому в классе AffiliateAdmin потребуется изменить метод configureRoutes:

<?php
# src/App/JoboardBundle/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:

<?php
# src/App/JoboardBundle/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('AppJoboardBundle: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('AppJoboardBundle: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/App/JoboardBundle/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/App/JoboardBundle/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 %}

Внутри AffiliateAdmin файла добавьте новые действия и кнопки в методе configureListFields, чтобы они появились на странице для каждого аккаунта:

<?php
# src/App/JoboardBundle/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', [
                'actions' => [
                    'activate'   => ['template' => 'AppJoboardBundle:AffiliateAdmin:list__action_activate.html.twig'],
                    'deactivate' => ['template' => 'AppJoboardBundle:AffiliateAdmin:list__action_deactivate.html.twig']
                ]
            ])
       ;
   }
   // ...
}

Теперь очистите кеш и проверьте что у вас получилось. На сегодня это все. В следующей части рассмотрим как отправлять email партнерам при активации их аккаунта.