Symfony2 Jobeet Day 16: The Mailer

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Yesterday, we added a read-only web service to Jobeet. Affiliates can now create an account but it needs to be activated by the administrator before it can be used. In order for the affiliate to get its token, we still need to implement the email notification. That’s what we will start doing in the coming lines.

The symfony framework comes bundled with one of the best PHP emailing solution: Swift Mailer. Of course, the library is fully integrated with symfony, with some cool features added on top of its default features. Let’s start by sending a simple email to notify the affiliate when his account has been activated and to give him the affiliate token. But first, you need to configure your environment:

# ...
    # ...
    mailer_transport:  gmail
    mailer_host:       ~
    mailer_user:       address@example.com
    mailer_password:   your_password
    # ...

For the code to work properly, you should change the address@example.com email address to a real one, along with your real password.

Do the same thing in your app/config/parameters_test.yml file.

After modifying the two files, clear the cache for both test and development environment:

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

Because we set the mailer transport to gmail, when you will replace the email address from “mailer_user”, you will put a google email address.

You can think of creating a Message as being similar to the steps you perform when you click the compose button in your mail client. You give it a subject, specify some recipients and write your message.

To create the message, you will:

  • call the newInstance() methond of Swift_message (refer to the Swift Mailer official documentation to learn more about this object).
  • set your sender address (From:) with setFrom() method.
  • set a subject line with setSubject() method.
  • set recipients with one of these methods: setTo(), setCc() or setBcc().
  • set a body with setBody().

Replace the activate action with the following code:

// ...

    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();

            $message = Swift_Message::newInstance()
                ->setSubject('Jobeet affiliate token')
                ->setFrom('address@example.com')
                ->setTo($affiliate->getEmail())
                ->setBody(
                    $this->renderView('IbwJobeetBundle:Affiliate:email.txt.twig', array('affiliate' => $affiliate->getToken())))
            ;

            $this->get('mailer')->send($message);
        } catch(Exception $e) {
            $this->get('session')->setFlash('sonata_flash_error', $e->getMessage());
        }

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

// ...

Sending the message is then as simple as calling the send() method on the mailer instance and passing the message as an argument.

For the message body, we created a new file, called email.txt.twig, that contains exactly what we want to inform the affiliate about.

Your affiliate account has been activated.
Your secret token is {{affiliate}}.
You can see the jobs list at the following addresses:
http://jobeet.local/app_dev.php/api/{{affiliate}}/jobs.xml
or http://jobeet.local/app_dev.php/api/{{affiliate}}/jobs.json
or http://jobeet.local/app_dev.php/api/{{affiliate}}/jobs.yaml

Now, let’s add the mailing functionality to the batchActionActivate too, so that even if we select multiple affiliate accounts to activate, they will receive their account activation email :

// ... 

    public function batchActionActivate(ProxyQueryInterface $selectedModelQuery)
    {
        // ...

        try {
            foreach($selectedModels as $selectedModel) {
                $selectedModel->activate();
                $modelManager->update($selectedModel);

                $message = Swift_Message::newInstance()
                    ->setSubject('Jobeet affiliate token')
                    ->setFrom('address@example.com')
                    ->setTo($selectedModel->getEmail())
                    ->setBody(
                        $this->renderView('IbwJobeetBundle:Affiliate:email.txt.twig', array('affiliate' => $selectedModel->getToken())))
                ;

                $this->get('mailer')->send($message);
            }
        } catch(Exception $e) {
            $this->get('session')->setFlash('sonata_flash_error', $e->getMessage());

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

        // ...
    }

// ...

The Tests

Now that we have seen how to send an email with the symfony mailer, let’s write some functional tests to ensure we did the right thing.

To test this new functionality, we need to be logged in. To log in, we will need an username and a password. That’s why we will start by creating a new fixture file, where we add the user admin:

namespace IbwJobeetBundleDataFixturesORM;

use DoctrineCommonPersistenceObjectManager;
use DoctrineCommonDataFixturesAbstractFixture;
use DoctrineCommonDataFixturesFixtureInterface;
use DoctrineCommonDataFixturesOrderedFixtureInterface;
use SymfonyComponentDependencyInjectionContainerAwareInterface;
use SymfonyComponentDependencyInjectionContainerInterface;
use IbwJobeetBundleEntityUser;

class LoadUserData implements FixtureInterface, OrderedFixtureInterface, ContainerAwareInterface
{
    /**
     * @var ContainerInterface
     */
    private $container;

    /**
     * {@inheritDoc}
     */
    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    /**
     * @param DoctrineCommonPersistenceObjectManager $em
     */
    public function load(ObjectManager $em)
    {
        $user = new User();
        $user->setUsername('admin');
        $encoder = $this->container
            ->get('security.encoder_factory')
            ->getEncoder($user)
        ;

        $encodedPassword = $encoder->encodePassword('admin', $user->getSalt());
        $user->setPassword($encodedPassword);

        $em->persist($user);
        $em->flush();
    }

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

In the tests, we will use the swiftmailer collector on the profiler to get information about the messages send on the previous requests. Now, let’s add some tests to check if the email is sent properly:

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class AffiliateAdminControllerTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testActivate()
    {
        $client = static::createClient();

        // Enable the profiler for the next request (it does nothing if the profiler is not available)
        $client->enableProfiler();
        $crawler = $client->request('GET', '/login');

        $form = $crawler->selectButton('login')->form(array(
            '_username'      => 'admin',
            '_password'      => 'admin'
        ));

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

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

        $crawler = $client->request('GET', '/admin/ibw/jobeet/affiliate/list');

        $link = $crawler->filter('.btn.edit_link')->link();
        $client->click($link);

        $mailCollector = $client->getProfile()->getCollector('swiftmailer');

        // Check that an e-mail was sent
        $this->assertEquals(1, $mailCollector->getMessageCount());

        $collectedMessages = $mailCollector->getMessages();
        $message = $collectedMessages[0];

        // Asserting e-mail data
        $this->assertInstanceOf('Swift_Message', $message);
        $this->assertEquals('Jobeet affiliate token', $message->getSubject());
        $this->assertRegExp(
            '/Your secret token is symfony/',
            $message->getBody()
        );
    }
}

If you run the test now, you’ll get and error. To prevent this for happening, go to your config_test.yml file and make sure that the profiler is enabled in the test environment. If it’s set to false, change it to true:

# ...

framework:
    test: ~
    session:
        storage_id: session.storage.mock_file
    profiler:
        enabled: true

# ...

Now, clear the cache, run the test command in your console and enjoy the green bar :

phpunit -c app src/Ibw/JobeetBundle/Tests/Controller/AffiliateAdminControllerTest

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 15: Web Services

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
With the addition of feeds on Jobeet, job seekers can now be informed of new jobs in real-time.

On the other side of the fence, when you post a job, you will want to have the greatest exposure possible. If your job is syndicated on a lot of small websites, you will have a better chance to find the right person. That’s the power of the long tail. Affiliates will be able to publish the latest posted jobs on their websites thanks to the web services we will develop today.

Affiliates

As we already said in day 2 of this tutorial, an affiliate retrieves the current active job list.

The fixtures

Let’s create a new fixture file for the affiliates:

namespace IbwJobeetBundleDataFixturesORM;

use DoctrineCommonPersistenceObjectManager;
use DoctrineCommonDataFixturesAbstractFixture;
use DoctrineCommonDataFixturesOrderedFixtureInterface;
use IbwJobeetBundleEntityAffiliate;

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->addCategorie($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->addCategorie($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
    }
}

Now, to persist the data defined in your fixture file, just run the following command:

php app/console doctrine:fixtures:load

In the fixture file, the tokens are hardcoded to simplify the testing, but when an actual user applies for an account, the token will need to be generated Let’s create a function to do that in our Affiliate class. Start by adding the setTokenValue method to lifecycleCallbacks section, inside your ORM file:

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

Now, the setTokenValue method will be generated inside the entity file when you will run the following command:

php app/console doctrine:generate:entities IbwJobeetBundle

Let’s modify the method now:

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

        return $this;
    }

Reload the data:

php app/console doctrine:fixtures:load

The Job Web Service

As always, when you create a new resource, it’s a good habbit to define the route first:

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

As usually, after you modify a routing file, you need to clear the cache:

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

The next step is to create the api action and the templates, that will share the same action. Let us now create a new controller file, called ApiController:

namespace IbwJobeetBundleController;

use SymfonyBundleFrameworkBundleControllerController;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use IbwJobeetBundleEntityAffiliate;
use IbwJobeetBundleEntityJob;
use IbwJobeetBundleRepositoryAffiliateRepository;

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));  
    }
}

To retrieve the affiliate using his token, we will create the getForToken() method. This method also verifies if the affiliate account is activated, so there is no need for us to check this one more time. Until now, we haven’t used the AffiliateRepository yet, so it doesn’t exist. To create it, modify the ORM file as following, then run the command you used before to generate the entities.

IbwJobeetBundleEntityAffiliate:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryAffiliateRepository
    # ...

Once created, it is ready to be used:

namespace IbwJobeetBundleRepository;

use DoctrineORMEntityRepository;

/**
 * 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(DoctrineOrmNoResultException $e){
            $affiliate = null;
        }

        return $affiliate;
    }
}

After identifying the affiliate by his token, we will use the getActiveJobs() method to give the affiliate the jobs he required, belonging to the selected categories. If you open your JobRepository file now, you will see that the getActiveJobs() method doesn’t share any connection with the affiliates. Because we want to reuse that method, we need to make some modifications inside of it:

// ...

    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();
    }

// ...

As you can see, we populate the jobs array using a function called asArray(). Let’s define it:

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'),
    );
}

The xml Format

Supporting the xml format is as simple as creating a template:

<?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>

The json Format

Support the JSON format is similar:

{% 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 %}

The yaml Format

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

If you try to call the web service with a non-valid token, you will receive a 404 page as a response, for all the formats. To see what you accomplished until now, access the following links: http://jobeet.local/app_dev.php/api/sensio-labs/jobs.xml or http://jobeet.local/app_dev.php/api/symfony/jobs.xml. Change the extension in the URL, depending on which format you prefer.

Web Service Tests

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;
use SymfonyComponentDomCrawlerCrawler;
use SymfonyComponentHttpFoundationHttpExceptionInterface;

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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

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

        $this->assertEquals('IbwJobeetBundleControllerApiController::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('IbwJobeetBundleControllerApiController::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('IbwJobeetBundleControllerApiController::listAction', $client->getRequest()->attributes->get('_controller'));

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

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

Inside the ApiControllerTest file, we test that the request formats are correctly received and the pages requested are correctly returned.

The Affiliate Application Form

Now that the web service is ready to be used, let’s create the account creation form for affiliates. For that, you need to write the HTML form, implement validation rules for each field, process the values to store them in a database, display error messages and repopulate fields in case of errors.

First, create a new controller file, named AffiliateController:

namespace IbwJobeetBundleController;

use SymfonyBundleFrameworkBundleControllerController;
use IbwJobeetBundleEntityAffiliate;
use IbwJobeetBundleFormAffiliateType;
use SymfonyComponentHttpFoundationRequest;
use IbwJobeetBundleEntityCategory;

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

Then, change the Affiliates link in the layout:

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

Now, we need to create an action to match the route from the link you just modified it earlier:

namespace IbwJobeetBundleController;

use SymfonyBundleFrameworkBundleControllerController;
use IbwJobeetBundleEntityAffiliate;
use IbwJobeetBundleFormAffiliateType;
use SymfonyComponentHttpFoundationRequest;
use IbwJobeetBundleEntityCategory;

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(),
        ));
    }
}

We have the name of the route, we have the action, but we do not have the route. so let’s create it:

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

Also, add this to your routing file:

# ...

IbwJobeetBundle_ibw_affiliate:
    resource: "@IbwJobeetBundle/Resources/config/routing/affiliate.yml"
    prefix:   /affiliate

The form file also needs to be created. But, even if the Affiliate has more fields, we won’t display them all, because some of them must not be editable by the end user. Create your Affiliate form:

namespace IbwJobeetBundleForm;

use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;
use IbwJobeetBundleEntityAffiliate;
use IbwJobeetBundleEntityCategory;

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' => 'IbwJobeetBundleEntityAffiliate',
        ));
    }

    public function getName()
    {
        return 'affiliate';
    }
}

Now, we need to decide whether or not the Affiliate object is valid after the form has applied the submitted data to it. To do this, add the following code to your validation file:

# ...

IbwJobeetBundleEntityAffiliate:
    constraints:
        - SymfonyBridgeDoctrineValidatorConstraintsUniqueEntity: email
    properties:
        url:
            - Url: ~
        email:
            - NotBlank: ~
            - Email: ~

In the validation schema, we used a new validator, called UniqueEntity. It validates that a particular field (or fields) in a Doctrine entity is (are) unique. This is commonly used, for example, to prevent a new user to register using an email address that already exists in the system.

Don’t forget to clear your cache after applying the validation constraints!

Finally, let’s create the view for the form too:

{% extends 'IbwJobeetBundle::layout.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/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 %}

When the user submits a form, the form data must be persisted into database, if valid. Add the new create action to your Affiliate controller:

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(),
        ));
    }
}

When submitting, the create action is performed, so we need to define the route:

# ...

ibw_affiliate_create:
    pattern: /create
    defaults: { _controller: "IbwJobeetBundle:Affiliate:create" }
    requirements: { _method: post }

After the affiliate registers, he is redirected to a waiting page. Let’s define that action and create the view too:

class AffiliateController extends Controller
{
    // ...

    public function waitAction()
    {
        return $this->render('IbwJobeetBundle: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 %}

Now, the route:

# ...

ibw_affiliate_wait:
    pattern: /wait
    defaults: { _controller: "IbwJobeetBundle:Affiliate:wait" }

After defining to routes, in order to work, you need to clear the cache.

Now, if you click on the Affiliates link on the homepage, you will be directed to the affiliate form page.

Tests

The last step is to write some functional tests for the new feature.

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;
use SymfonyComponentDomCrawlerCrawler;

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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

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

        $this->assertEquals('IbwJobeetBundleControllerAffiliateController::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('IbwJobeetBundleControllerAffiliateController::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('IbwJobeetBundleControllerAffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));

        return $client;
    }

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

        $this->assertEquals('IbwJobeetBundleControllerAffiliateController::waitAction', $client->getRequest()->attributes->get('_controller'));
    }
}

The Affiliate Backend

For the backend, we will work with SonataAdminBundle. As we said before, after an affiliate registers, he needs to wait for the admin to activate his account. So, when the admin will access the affiliates page, he will see only the inactivated accounts, to help him be more productive.

First of all, you need to declare the new affiliate service inside your services.yml file:

# ...
    ibw.jobeet.admin.affiliate:
        class: IbwJobeetBundleAdminAffiliateAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: jobeet, label: Affiliates }
        arguments:
            - ~
            - IbwJobeetBundleEntityAffiliate
            - 'IbwJobeetBundle:AffiliateAdmin'

After that, create the Admin file:

namespace IbwJobeetBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleValidatorErrorElement;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleShowShowMapper;
use IbwJobeetBundleEntityAffiliate;

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')
        ;
    }
}

To help the administrator, we want to display only the inactivated accounts. This can be made by setting the ‘is_active’ filter to false:

// ...
    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
    );

// ...

Now, create the AffiliateAdmin controller file:

namespace IbwJobeetBundleController;

use SonataAdminBundleControllerCRUDController as Controller;
use SonataDoctrineORMAdminBundleDatagridProxyQuery as ProxyQueryInterface;
use SymfonyComponentHttpFoundationRedirectResponse;

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

Let’s create the activate and deactivate batch actions:

namespace IbwJobeetBundleController;

use SonataAdminBundleControllerCRUDController as Controller;
use SonataDoctrineORMAdminBundleDatagridProxyQuery as ProxyQueryInterface;
use SymfonyComponentHttpFoundationRedirectResponse;

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()));
    }
}

For the new batch actions to be functional, we have to add them in the getBatchActions from the Admin class:

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;
    }
}

For this to work, you need to add the two methods, activate and deactivate, in the entity file:

// ...

    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;
    }

Let’s now create two individual actions, activate and deactivate, for each item. Firstly, we will create routes for them. That’s why, in your Admin class, you will extend the configureRoutes function:

use SonataAdminBundleRouteRouteCollection;

class AffiliateAdmin extends Admin
{
    // ...

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

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

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

It’s time to implement the actions in the AdminController:

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()));
    }
}

Now, create the templates for the new added action buttons:

{% 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 %}
{% 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 %}

Inside your Admin file, add the new actions and buttons to the configureListFields function, so that they would appear on the page, to each account individually:

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'))))
        ;
    }
    /// ...
}

Now, clear your cache and try it on!

That’s all for today! Tomorrow, we will take care of the emails the affiliates will receive when their accounts have been activated.
Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 14: Feeds

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
If you are looking for a job, you will probably want to be informed as soon as a new job is posted. Because it is not very convenient to check the website every other hour, we will add several job feeds here to keep our Jobeet users up-to-date.

Template Formats

Templates are a generic way to render content in any format. And while in most cases you’ll use templates to render HTML content, a template can just as easily generate JavaScript, CSS, XML or any other format.

For example, the same “resource” is often rendered in several different formats. To render an article index page in XML, simply include the format in the template name:

  • XML template name: AcmeArticleBundle:Article:index.xml.twig
  • XML template filename: index.xml.twig

In reality, this is nothing more than a naming convention and the template isn’t actually rendered differently based on its format.

In many cases, you may want to allow a single controller to render multiple different formats based on the “request format”. For that reason, a common pattern is to do the following:

public function indexAction()
{
    $format = $this-&gt;getRequest()-&gt;getRequestFormat();

    return $this-&gt;render('AcmeBlogBundle:Blog:index.'.$format.'.twig');
}

The getRequestFormat on the Request object defaults to html, but can return any other format based on the format requested by the user. The request format is most often managed by the routing, where a route can be configured so that /contact sets the request format to html while /contact.xml sets the format to xml.

To create links that include the format parameter, include a _format key in the parameter hash:

<a href="{{ path('article_show', {'id': 123, '_format': 'pdf'}) }}">
    PDF Version
</a>

Feeds

LATEST JOBS FEED

Supporting different formats is as easy as creating different templates. To create an Atom feed for the latest jobs, create an index.atom.twig template:

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Jobeet</title>
    <subtitle>Latest Jobs</subtitle>
    <link href="" rel="self"/>
    <link href=""/>
    <updated></updated>
    <author><name>Jobeet</name></author>
    <id>Unique Id</id>

    <entry>
        <title>Job title</title>
        <link href="" />
        <id>Unique id</id>
        <updated></updated>
        <summary>Job description</summary>
        <author><name>Company</name></author>
    </entry>
</feed>

In the Jobeet footer, update the link to the feed:

<!-- ... -->

<li class="feed"><a href="{{ path('ibw_job', {'_format': 'atom'}) }}">Full feed</a></li>

<!-- ... -->

Add a <link> tag in the head section of the layout to allow automatic discover by the browser of our feed:

<!-- ... -->

<link rel="alternate" type="application/atom+xml" title="Latest Jobs" href="{{ url('ibw_job', {'_format': 'atom'}) }}" />

<!-- ... -->

In the JobController change the indexAction to render the template according to the _format:

// ...

$format = $this->getRequest()->getRequestFormat();

return $this->render('IbwJobeetBundle:Job:index.'.$format.'.twig', array(
    'categories' => $categories
));

// ...

Replace the Atom template header with the following code:

<!-- ... -->

    <title>Jobeet</title>
    <subtitle>Latest Jobs</subtitle>
    <link href="{{ url('ibw_job', {'_format': 'atom'}) }}" rel="self"/>
    <link href="{{ url('ibw_jobeet_homepage') }}"/>
    <updated>{{ lastUpdated }}</updated>
    <author><name>Jobeet</name></author>
    <id>{{ feedId }}</id>

<!-- ... -->

From the JobController (index action) we have to send the lastUpdated and feedId to the template:

// ...

        $latestJob = $em->getRepository('IbwJobeetBundle:Job')->getLatestPost();

        if($latestJob) {
            $lastUpdated = $latestJob->getCreatedAt()->format(DATE_ATOM);
        } else {
            $lastUpdated = new DateTime();
            $lastUpdated = $lastUpdated->format(DATE_ATOM);
        }

        $format = $this->getRequest()->getRequestFormat();
        return $this->render('IbwJobeetBundle:Job:index.'.$format.'.twig', array(
               'categories' => $categories,
               'lastUpdated' => $lastUpdated,
               'feedId' => sha1($this->get('router')->generate('ibw_job', array('_format'=> 'atom'), true)),
        ));
// ...

To get the date of the latest post, we have to create the getLatestPost() method in the JobRepository:

// ...

    public function getLatestPost($category_id = null)
    {
        $query = $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')
            ->setMaxResults(1);

        if($category_id) {
            $query->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        try{
            $job = $query->getQuery()->getSingleResult();
        } catch(DoctrineOrmNoResultException $e){
            $job = null;
        }

        return $job;    
    }
// ...

The feed entries can be generated with the following code:

{% for category in categories %}
    {% for entity in category.activejobs %}
        <entry>
            <title>{{ entity.position }} ({{ entity.location }})</title>
            <link href="{{ url('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}" />
            <id>{{ entity.id }}</id>
            <updated>{{ entity.createdAt.format(constant('DATE_ATOM')) }}</updated>
            <summary type="xhtml">
                <div xmlns="http://www.w3.org/1999/xhtml">
                    {% if entity.logo %}
                        <div>
                            <a href="{{ entity.url }}">
                                <img src="http://{{ app.request.host }}/uploads/jobs/{{ entity.logo }}" alt="{{ entity.company }} logo" />
                            </a>
                        </div>
                    {% endif %}
                    <div>
                        {{ entity.description|nl2br }}
                    </div>
                    <h4>How to apply?</h4>
                    <p>{{ entity.howtoapply }}</p>
                </div>
            </summary>
            <author><name>{{ entity.company }}</name></author>
        </entry>
    {% endfor %}
{% endfor %}

LATEST JOBS IN A CATEGORY FEED

One of the goals of Jobeet is to help people find more targeted jobs. So, we need to provide a feed for each category.

First, let’s update the links to category feeds in the templates:

<div class="feed">
    <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, '_format': 'atom' }) }}">Feed</a>
</div>
<div class="feed">
    <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, '_format': 'atom' }) }}">Feed</a>
</div>

Update the CategoryController showAction to render the corresponding template:

// ...
    public function showAction($slug, $page)
    {
        $em = $this->getDoctrine()->getManager();

        $category = $em->getRepository('IbwJobeetBundle:Category')->findOneBySlug($slug);

        if (!$category) {
            throw $this->createNotFoundException('Unable to find Category entity.');
        }

        $latestJob = $em->getRepository('IbwJobeetBundle:Job')->getLatestPost($category->getId());

        if($latestJob) {
            $lastUpdated = $latestJob->getCreatedAt()->format(DATE_ATOM); 
        } else {
            $lastUpdated = new DateTime();
            $lastUpdated = $lastUpdated->format(DATE_ATOM);
        }

        $total_jobs = $em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId());
        $jobs_per_page = $this->container->getParameter('max_jobs_on_category');
        $last_page = ceil($total_jobs / $jobs_per_page);
        $previous_page = $page > 1 ? $page - 1 : 1;
        $next_page = $page < $last_page ? $page + 1 : $last_page; 
        $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $jobs_per_page, ($page - 1) * $jobs_per_page));

        $format = $this->getRequest()->getRequestFormat();

        return $this->render('IbwJobeetBundle:Category:show.' . $format . '.twig', array(
            'category' => $category,
            'last_page' => $last_page,
            'previous_page' => $previous_page,
            'current_page' => $page,
            'next_page' => $next_page,
            'total_jobs' => $total_jobs,
            'feedId' => sha1($this->get('router')->generate('IbwJobeetBundle_category', array('slug' => $category->getSlug(), 'format' => 'atom'), true)),
            'lastUpdated' => $lastUpdated    
        ));
    }

Eventually, create the show.atom.twig template:

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Jobeet ({{ category.name }})</title>
    <subtitle>Latest Jobs</subtitle>
    <link href="{{ url('IbwJobeetBundle_category', { 'slug': category.slug, '_format': 'atom' }) }}" rel="self" />
    <updated>{{ lastUpdated }}</updated>
    <author><name>Jobeet</name></author>
    <id>{{ feedId }}</id>

    {% for entity in category.activejobs %}
        <entry>
            <title>{{ entity.position }} ({{ entity.location }})</title>
            <link href="{{ url('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}" />
            <id>{{ entity.id }}</id>
            <updated>{{ entity.createdAt.format(constant('DATE_ATOM')) }}</updated>
            <summary type="xhtml">
                <div xmlns="http://www.w3.org/1999/xhtml">
                    {% if entity.logo %}
                        <div>
                            <a href="{{ entity.url }}">
                                <img src="http://{{ app.request.host }}/uploads/jobs/{{ entity.logo }}" alt="{{ entity.company }} logo" />
                            </a>
                        </div>
                    {% endif %}
                    <div>
                        {{ entity.description|nl2br }}
                    </div>
                    <h4>How to apply?</h4>
                    <p>{{ entity.howtoapply }}</p>
                </div>
            </summary>
            <author><name>{{ entity.company }}</name></author>
        </entry>
    {% endfor %}
</feed>

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 13: Security

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

Securing the Application

Security is a two-step process whose goal is to prevent a user from accessing a resource that he/she should not have access to. In the first step of the process, the authentication, the security system identifies who the user is by requiring the user to submit some sort of identification. Once the system knows who you are, the next step, called the authorization, is to determine if you should have access to a given resource (it checks to see if you have privileges to perform a certain action).

The security component can be configured via your application configuration using the security.yml file from the app/config folder. To secure our application change your security.yml file:

security:
    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    firewalls:
        dev:
            pattern:  ^/(_(profiler|wdt)|css|images|js)/
            security: false

        secured_area:
            pattern:    ^/
            anonymous: ~
            form_login:
                login_path:  /login
                check_path:  /login_check
                default_target_path: ibw_jobeet_homepage

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }

    providers:
        in_memory:
            memory:
                users:
                    admin: { password: adminpass, roles: 'ROLE_ADMIN' }

    encoders:
        SymfonyComponentSecurityCoreUserUser: plaintext

This configuration will secure the /admin section of the website (all urls that start with /admin) and will allow only users with ROLE_ADMIN to access it (see the access_control section). In this example the admin user is defined in the configuration file (the providers section) and the password is not encoded (encoders).

For authenticating users, a traditional login form will be used, but we need to implement it. First, create two routes: one that will display the login form (i.e. /login) and one that will handle the login form submission (i.e. /login_check):

login:
    pattern:   /login
    defaults:  { _controller: IbwJobeetBundle:Default:login }
login_check:
    pattern:   /login_check

# ...

 

We will not need to implement a controller for the /login_check URL as the firewall will automatically catch and process any form submitted to this URL. But you need to create a route so that it can be used to generate the form submission URL in the login template below.

Next, let’s create the action that will display the login form:

namespace IbwJobeetBundleController;

use SymfonyBundleFrameworkBundleControllerController;
use SymfonyComponentSecurityCoreSecurityContext;

class DefaultController extends Controller
{
    // ...

    public function loginAction()
    {
        $request = $this->getRequest();
        $session = $request->getSession();

        // get the login error if there is one
        if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
            $error = $request->attributes->get(SecurityContext::AUTHENTICATION_ERROR);
        } else {
            $error = $session->get(SecurityContext::AUTHENTICATION_ERROR);
            $session->remove(SecurityContext::AUTHENTICATION_ERROR);
        }

        return $this->render('IbwJobeetBundle:Default:login.html.twig', array(
            // last username entered by the user
            'last_username' => $session->get(SecurityContext::LAST_USERNAME),
            'error'         => $error,
        ));
    }
}

When the user submits the form, the security system automatically handles the form submission for you. If the user had submitted an invalid username or password, this action reads the form submission error from the security system so that it can be displayed back to the user. Your only job is to display the login form and any login errors that may have occurred, but the security system itself takes care of checking the submitted username and password and authenticating the user.

Finally, let’s create the corresponding template:

{% if error %}
    <div>{{ error.message }}</div>
{% endif %}

<form action="{{ path('login_check') }}" method="post">
    <label for="username">Username:</label>
    <input type="text" id="username" name="_username" value="{{ last_username }}" />

    <label for="password">Password:</label>
    <input type="password" id="password" name="_password" />

    <button type="submit">login</button>
</form>

Now, if you try to access http://jobeet.local/app_dev.php/admin/dashboard url, the login form will show and you will have to enter the username and password defined in security.yml (admin/adminpass) to get to the admin section of Jobeet.

User Providers

During authentication, the user submits a set of credentials (usually a username and password). The job of the authentication system is to match those credentials against some pool of users. So where does this list of users come from?

In Symfony2, users can come from anywhere – a configuration file, a database table, a web service, or anything else you can dream up. Anything that provides one or more users to the authentication system is known as a “user provider”. Symfony2 comes standard with the two most common user providers: one that loads users from a configuration file and one that loads users from a database table.

Above, we used the first case: specifying users in a configuration file.

# ...

providers:
    in_memory:
        memory:
            users:
                admin: { password: adminpass, roles: 'ROLE_ADMIN' }

# ...

But you will usually want the users to be stored in a database table. To do this we will add a new user table to our jobeet database. First let’s create the orm for this new table:

IbwJobeetBundleEntityUser:
    type: entity
    table: user
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        username:
            type: string
            length: 255
        password:
            type: string
            length: 255

Now run the doctrine:generate:entities command to create the new User entity class:

php app/console doctrine:generate:entities IbwJobeetBundle

And update the database:

php app/console doctrine:schema:update --force

The only requirement for your new user class is that it implements the UserInterface interface. This means that your concept of a “user” can be anything, as long as it implements this interface. Open the User.php file and edit it as follows:

namespace IbwJobeetBundleEntity;

use SymfonyComponentSecurityCoreUserUserInterface;
use DoctrineORMMapping as ORM;

/**
 * User
 */
class User implements UserInterface
{
    /**
     * @var integer
     */
    private $id;

    /**
     * @var string
     */
    private $username;

    /**
     * @var string
     */
    private $password;

    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set username
     *
     * @param string $username
     * @return User
     */
    public function setUsername($username)
    {
        $this->username = $username;

    }

    /**
     * Get username
     *
     * @return string 
     */
    public function getUsername()
    {
        return $this->username;
    }

    /**
     * Set password
     *
     * @param string $password
     * @return User
     */
    public function setPassword($password)
    {
        $this->password = $password;

    }

    /**
     * Get password
     *
     * @return string 
     */
    public function getPassword()
    {
        return $this->password;
    }

    public function getRoles()
    {
        return array('ROLE_ADMIN');
    }

    public function getSalt()
    {
        return null;
    }

    public function eraseCredentials()
    {

    }

    public function equals(User $user)
    {
        return $user->getUsername() == $this->getUsername();
    }        
}

To the generated entity we added the methods required by the UserInterface class: getRoles, getSalt, eraseCredentials and equals.

Next, configure an entity user provider, and point it to your User class:

...

    providers:
        main:
            entity: { class: IbwJobeetBundleEntityUser, property: username }

    encoders:
        IbwJobeetBundleEntityUser: sha512

We also changed the encoder for our new User class to use the sha512 algorithm to encrypt passwords.

Now everything is set up but we need to create our first user. To do this we will create a new symfony command:

namespace IbwJobeetBundleCommand;

use SymfonyBundleFrameworkBundleCommandContainerAwareCommand;
use SymfonyComponentConsoleInputInputArgument;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleInputInputOption;
use SymfonyComponentConsoleOutputOutputInterface;
use IbwJobeetBundleEntityUser;

class JobeetUsersCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this
            ->setName('ibw:jobeet:users')
            ->setDescription('Add Jobeet users')
            ->addArgument('username', InputArgument::REQUIRED, 'The username')
            ->addArgument('password', InputArgument::REQUIRED, 'The password')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $username = $input->getArgument('username');
        $password = $input->getArgument('password');

        $em = $this->getContainer()->get('doctrine')->getManager();

        $user = new User();
        $user->setUsername($username);
        // encode the password
        $factory = $this->getContainer()->get('security.encoder_factory');
        $encoder = $factory->getEncoder($user);
        $encodedPassword = $encoder->encodePassword($password, $user->getSalt());
        $user->setPassword($encodedPassword);
        $em->persist($user);
        $em->flush();

        $output->writeln(sprintf('Added %s user with password %s', $username, $password));
    }
}

To add your first user run:

php app/console ibw:jobeet:users admin admin

This will create the admin user with the password admin. You can use it to login to the admin section.

Logout

Logging out is handled automatically by the firewall. All you have to do is to activate the logout config parameter:

security:
    firewalls:
        # ...
        secured_area:
            # ...
            logout:
                path:   /logout
                target: /
    # ...

You will not need to implement a controller for the /logout URL as the firewall takes care of everything. Let’s create a route so that you can use it to generate the URL:

# ...

logout:
    pattern:   /logout

# ...

Once this is configured, sending a user to /logout (or whatever you configure the path to be), will un-authenticate the current user. The user will then be sent to the homepage (the value defined by the target parameter).

All left to do is to add the logout link to our admin section. To do this we will override the user_block.html.twig from SonataAdminBundle. Create the user_block.html.twig file in app/Resources/SonataAdminBundle/views/Core folder:

{% block user_block %}<a href="{{ path('logout') }}">Logout</a>{% endblock%}

Now, if you try to enter the admin section (clear the cache first), you will be asked for an username and password and then, the logout link will be shown in the top-right corner.

The User Session

Symfony2 provides a nice session object that you can use to store information about the user between requests. By default, Symfony2 stores the attributes in a cookie by using the native PHP sessions.

You can store and retrieve information from the session easily from the controller:

$session = $this->getRequest()->getSession();

// store an attribute for reuse during a later user request
$session->set('foo', 'bar');

// in another controller for another request
$foo = $session->get('foo');

Unfortunately, the Jobeet user stories have no requirement that includes storing something in the user session. So let’s add a new requirement: to ease job browsing, the last three jobs viewed by the user should be displayed in the menu with links to come back to the job page later on.

When a user access a job page, the displayed job object needs to be added in the user history and stored in the session:

// ...

public function showAction($id)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->getActiveJob($id);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $session = $this->getRequest()->getSession();

    // fetch jobs already stored in the job history
    $jobs = $session->get('job_history', array());

    // store the job as an array so we can put it in the session and avoid entity serialize errors
    $job = array('id' => $entity->getId(), 'position' =>$entity->getPosition(), 'company' => $entity->getCompany(), 'companyslug' => $entity->getCompanySlug(), 'locationslug' => $entity->getLocationSlug(), 'positionslug' => $entity->getPositionSlug());

    if (!in_array($job, $jobs)) {
        // add the current job at the beginning of the array
        array_unshift($jobs, $job);

        // store the new job history back into the session
        $session->set('job_history', array_slice($jobs, 0, 3));
    }

    $deleteForm = $this->createDeleteForm($id);

    return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
        'entity'      => $entity,
        'delete_form' => $deleteForm->createView(),
    ));
}

In the layout, add the following code before the #content div:

<!-- ... -->

<div id="job_history">
    Recent viewed jobs:
    <ul>
        {% for job in app.session.get('job_history') %}
            <li>
                <a href="{{ path('ibw_job_show', { 'id': job.id, 'company': job.companyslug, 'location': job.locationslug, 'position': job.positionslug }) }}">{{ job.position }} - {{ job.company }}</a>
            </li>
        {% endfor %}
    </ul>
</div>

<div id="content">

<!-- ... -->

Flash Messages

Flash messages are small messages you can store on the user’s session for exactly one additional request. This is useful when processing a form: you want to redirect and have a special message shown on the next request. We already used flash messages in our project when we publish a job:

// ...

public function publishAction($token)
{
    // ...

    $this->get('session')->getFlashBag()->add('notice', 'Your job is now online for 30 days.');

    // ...
}

The first argument of the getFlashBag()->add() function is the identifier of the flash and the second one is the message to display. You can define whatever flashes you want, but notice and error are two of the more common ones.

To show the flash messages to the user you have to include them in the template. We did this in the layout.html.twig template:

<!-- ... -->

{% for flashMessage in app.session.flashbag.get('notice') %}
    <div>
        {{ flassMessage }}
    </div>
{% endfor %}

<!-- ... -->

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 12: Sonata Admin Bundle

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
With the addition we made in Day 11 on Jobeet, the application is now fully usable by job seekers and job posters. It’s time to talk a bit about the admin section of our application. Today, thanks to the Sonata Admin Bundle, we will develop a complete admin interface for Jobeet in less than an hour.

 

Installation of the Sonata Admin Bundle

Start by downloading SonataAdminBundle and its dependencies to the vendor directory:

php composer.phar require sonata-project/admin-bundle

To install the latest version of the SonataAdminBundle and its dependencies, give * as input.

ibw@ubuntu:/var/www/jobeet$ php composer.phar require sonata-project/admin-bundle
Please provide a version constraint for the sonata-project/admin-bundle requirement: *

We will also need to install the SonataDoctrineORMADminBundle:

php composer.phar require sonata-project/doctrine-orm-admin-bundle

Now, we need to declare these new bundles and dependencies, so go to your AppKernel.php file and add the following code:

// ...
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new SonataAdminBundleSonataAdminBundle(),
            new SonataBlockBundleSonataBlockBundle(),
            new SonatajQueryBundleSonatajQueryBundle(),
            new SonataDoctrineORMAdminBundleSonataDoctrineORMAdminBundle(),
            new KnpBundleMenuBundleKnpMenuBundle(),
        );
    }

// ...

You will need to alter your config file as well. Add the following at the end:

# ...
sonata_admin:
    title: Jobeet Admin

sonata_block:
    default_contexts: [cms]
    blocks:
        sonata.admin.block.admin_list:
            contexts:   [admin]

        sonata.block.service.text:
        sonata.block.service.action:
        sonata.block.service.rss:

Also, look for the translator key and uncomment if it is commented:

# ...
framework:
    # ...
    translator: { fallback: %locale%}
    # ...
#...

For your application to work, you need to import the admin routes into the application’s routing file:

admin:
    resource: '@SonataAdminBundle/Resources/config/routing/sonata_admin.xml'
    prefix: /admin

_sonata_admin:
    resource: .
    type: sonata_admin
    prefix: /admin

# ...
php app/console assets:install web --symlink

Do not forget to delete your cache:

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

You should now be able to access the admin dashboard using the following url: http://jobeet.local/app_dev.php/admin/dashboard

The CRUD Controller

The CRUD controller contains the basic CRUD actions. It is related to one Admin class by mapping the controller name to the correct Admin instance. Any or all actions can be overwritten to suit the project’s requirements. The controller uses the Admin class to construct the different actions. Inside the controller, the Admin object is accessible through the configuration property.

Now let’s create a controller for each entity. First, for the Category entity:

namespace IbwJobeetBundleController;

use SonataAdminBundleControllerCRUDController as Controller;

class CategoryAdminController extends Controller
{
    // Your code will be here
}

And now for the Job:

namespace IbwJobeetBundleController;

use SonataAdminBundleControllerCRUDController as Controller;

class JobAdminController extends Controller
{
    // Your code will be here
}

Creating the Admin class

The Admin class represents the mapping of your model and administration sections (forms, list, show). The easiest way to create an admin class for your model is to extend the SonataAdminBundleAdminAdmin class. We will create the Admin classes in the Admin folder of our bundle. Start by creating the Admin directory and then, the Admin class for categories:

namespace IbwJobeetBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleValidatorErrorElement;
use SonataAdminBundleFormFormMapper;

class CategoryAdmin extends Admin
{
    // Your code will be here
}

And for jobs:

namespace IbwJobeetBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleValidatorErrorElement;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleShowShowMapper;
use IbwJobeetBundleEntityJob;

class JobAdmin extends Admin
{
    // Your code will be here
}

Now we need to add each admin class in the services.yml configuration file:

services:
    ibw.jobeet.admin.category:
        class: IbwJobeetBundleAdminCategoryAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: jobeet, label: Categories }
        arguments:
            - ~
            - IbwJobeetBundleEntityCategory
            - 'IbwJobeetBundle:CategoryAdmin'

    ibw.jobeet.admin.job:
        class: IbwJobeetBundleAdminJobAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: jobeet, label: Jobs }
        arguments:
            - ~
            - IbwJobeetBundleEntityJob
            - 'IbwJobeetBundle:JobAdmin'

At this point, we can see in the dashboard the Jobeet group and, inside it, the Job and Category modules, with their respective add and list links.

Configuration of Admin classes

If you follow any link right now, nothing will happen. That’s because we haven’t configure the fields that belong to the list and the form. Let’s do a basic configuration, first for the categories:

namespace IbwJobeetBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleValidatorErrorElement;
use SonataAdminBundleFormFormMapper;

class CategoryAdmin extends Admin
{
    // setup the default sort column and order
    protected $datagridValues = array(
        '_sort_order' => 'ASC',
        '_sort_by' => 'name'
    );

    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('name')
            ->add('slug')
        ;
    }

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

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('name')
            ->add('slug')
        ;
    }
}

And now for jobs:

namespace IbwJobeetBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleValidatorErrorElement;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleShowShowMapper;
use IbwJobeetBundleEntityJob;

class JobAdmin extends Admin
{
    // setup the defaut sort column and order
    protected $datagridValues = array(
        '_sort_order' => 'DESC',
        '_sort_by' => 'created_at'
    );

    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('category')
            ->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true))
            ->add('company')
            ->add('file', 'file', array('label' => 'Company logo', 'required' => false))
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('is_public')
            ->add('email')
            ->add('is_activated')
        ;
    }

    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('category')
            ->add('company')
            ->add('position')
            ->add('description')
            ->add('is_activated')
            ->add('is_public')
            ->add('email')
            ->add('expires_at')
        ;
    }

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('company')
            ->add('position')
            ->add('location')
            ->add('url')
            ->add('is_activated')
            ->add('email')
            ->add('category')
            ->add('expires_at')
            ->add('_action', 'actions', array(
                'actions' => array(
                    'view' => array(),
                    'edit' => array(),
                    'delete' => array(),
                )
            ))
        ;
    }

    protected function configureShowField(ShowMapper $showMapper)
    {
        $showMapper
            ->add('category')
            ->add('type')
            ->add('company')
            ->add('webPath', 'string', array('template' => 'IbwJobeetBundle:JobAdmin:list_image.html.twig'))
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('is_public')
            ->add('is_activated')
            ->add('token')
            ->add('email')
            ->add('expires_at')
        ;
    }
}

For the show action we used a custom template to show the logo of the company:

<tr>
    <th>Logo</th>
    <td><img src="{{ asset(object.webPath) }}" /></td>
</tr>

With this, we created a basic administration module with operations for our jobs and categories. Some of the features you will find when using it are:

  • The list of objects is paginated
  • The list is sortable
  • The list can be filtered
  • Objects can be created, edited, and deleted
  • Selected objects can be deleted in a batch
  • The form validation is enabled
  • Flash messages give immediate feedback to the user

Batch Actions

Batch actions are actions triggered on a set of selected models (all of them or only a specific subset). You can easily add some custom batch action in the list view. By default, the delete action allows you to remove several entries at once.

To add a new batch action we have to override the getBatchActions from the Admin class. We will define here a new extend action:

// ...

public function getBatchActions()
{
    // retrieve the default (currently only the delete action) actions
    $actions = parent::getBatchActions();

    // check user permissions
    if($this->hasRoute('edit') && $this->isGranted('EDIT') && $this->hasRoute('delete') && $this->isGranted('DELETE')) {
        $actions['extend'] = array(
            'label'            => 'Extend',
            'ask_confirmation' => true // If true, a confirmation will be asked before performing the action
        );

    }

    return $actions;
}

The method batchActionExtend form the JobAdminController will be executed to achieve the core logic. The selected models are passed to the method through a query argument retrieving them. If for some reason it makes sense to perform your batch action without the default selection method (for example you defined another way, at template level, to select model at a lower granularity), the passed query is null.

namespace IbwJobeetBundleController;

use SonataAdminBundleControllerCRUDController as Controller;
use SonataDoctrineORMAdminBundleDatagridProxyQuery as ProxyQueryInterface;
use SymfonyComponentHttpFoundationRedirectResponse;

class JobAdminController extends Controller
{
    public function batchActionExtend(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->extend();
                $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 jobs validity has been extended until %s.', date('m/d/Y', time() + 86400 * 30)));

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

Let’s add a new batch action that will delete all jobs that have not been activated by the poster for more than 60 days. For this action we don’t need to select any jobs from the list because the logic of the action will search for the matching records and delete them.

// ...

public function getBatchActions()
{
    // retrieve the default (currently only the delete action) actions
    $actions = parent::getBatchActions();

    // check user permissions
    if($this->hasRoute('edit') && $this->isGranted('EDIT') && $this->hasRoute('delete') && $this->isGranted('DELETE')){
        $actions['extend'] = array(
            'label'            => 'Extend',
            'ask_confirmation' => true // If true, a confirmation will be asked before performing the action
        );

        $actions['deleteNeverActivated'] = array(
            'label'            => 'Delete never activated jobs',
            'ask_confirmation' => true // If true, a confirmation will be asked before performing the action
        );
    }

    return $actions;
}

In addition to create the batchActionDeleteNeverActivated action, we will create a new method in our JobAdminController, batchActionDeleteNeverActivatedIsRelevant, that gets executed before any confirmation, to make sure there is actually something to confirm (in our case it will always return true because the selection of the jobs to be deleted is handled by the logic found in the JobRepository::cleanup() method.

// ...

public function batchActionDeleteNeverActivatedIsRelevant()
{
    return true;
}

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

    $em = $this->getDoctrine()->getManager();
    $nb = $em->getRepository('IbwJobeetBundle:Job')->cleanup(60);

    if ($nb) {
        $this->get('session')->getFlashBag()->add('sonata_flash_success',  sprintf('%d never activated jobs have been deleted successfully.', $nb));
    } else {
        $this->get('session')->getFlashBag()->add('sonata_flash_info',  'No job to delete.');
    }

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

That’s all for today! Tomorrow, we will see how to secure the admin section with a username and a password. This will be the occasion to talk about the symfony2 security.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 11: Testing your Forms

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
In day 10, we created our first form with Symfony 2.3. People are now able to post a new job on Jobeet but we ran out of time before we could add some tests. That’s what we will do along these lines.

Submitting a Form

Let’s open the JobControllerTest file to add functional tests for the job creation and validation process. At the end of the file, add the following code to get the job creation page:

// ...

public function testJobForm()
{
    $client = static::createClient();

    $crawler = $client->request('GET', '/job/new');
    $this->assertEquals('IbwJobeetBundleControllerJobController::newAction', $client->getRequest()->attributes->get('_controller'));
}

To select forms we will use the selectButton() method. This method can select button tags and submit input tags. Once you have a Crawler representing a button, call the form() method to get a Form instance for the form wrapping the button node:

$form = $crawler->selectButton('Submit Form')->form();

The above example selects an input of type submit using its value attribute “Submit Form".

When calling the form() method, you can also pass an array of field values that overrides the default ones:

$form = $crawler->selectButton('submit')->form(array(
    'name' => 'Fabien',
    'my_form[subject]' => 'Symfony Rocks!'
));

It is now time to actually select and pass valid values to the form:

// ...

public function testJobForm()
{
    $client = static::createClient();

    $crawler = $client->request('GET', '/job/new');
    $this->assertEquals('IbwJobeetBundleControllerJobController::newAction', $client->getRequest()->attributes->get('_controller'));

    $form = $crawler->selectButton('Preview your job')->form(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[file]'         => __DIR__.'/../../../../../web/bundles/ibwjobeet/images/sensio-labs.gif',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
    ));

    $client->submit($form);
    $this->assertEquals('IbwJobeetBundleControllerJobController::createAction', $client->getRequest()->attributes->get('_controller'));
}

The browser also simulates file uploads if you pass the absolute path to the file to upload.

After submitting the form, we checked that the executed action is create.

Testing the Form

If the form is valid, the job should have been created and the user redirected to the preview page:

public function testJobForm()
{
    // ...
    $client->followRedirect();
    $this->assertEquals('IbwJobeetBundleControllerJobController::previewAction', $client->getRequest()->attributes->get('_controller'));
}

Testing the Database Record

Eventually, we want to test that the job has been created in the database and check that the is_activated column is set to false as the user has not published it yet.

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

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.location = :location AND j.is_activated IS NULL AND j.is_public = 0');
    $query->setParameter('location', 'Atlanta, USA');
    $this->assertTrue(0 < $query->getSingleScalarResult());
}

Testing for Errors

The job form creation works as expected when we submit valid values. Let’s add a test to check the behavior when we submit non-valid data:

public function testJobForm()
{
    // ...
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array(
        'job[company]'      => 'Sensio Labs',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[email]'        => 'not.an.email',
    ));
    $crawler = $client->submit($form);

    // check if we have 3 errors
    $this->assertTrue($crawler->filter('.error_list')->count() == 3);

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

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

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

Now, we need to test the admin bar found on the job preview page. When a job has not been activated yet, you can edit, delete, or publish the job. To test those three actions, we will need to first create a job. But that’s a lot of copy and paste, so let’s add a job creator method in the JobControllerTest class:

// ...

public function createJob($values = array())
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array_merge(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
  ), $values));

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

    return $client;
}

The createJob() method creates a job, follows the redirect and returns the browser. You can also pass an array of values that will be merged with some default values.

Testing the Publish action is now more simple:

public function testPublishJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO1'));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Publish')->form();
    $client->submit($form);

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

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.position = :position AND j.is_activated = 1');
    $query->setParameter('position', 'FOO1');
    $this->assertTrue(0 < $query->getSingleScalarResult());
}

Testing the Delete action is quite similar:

// ...

public function testDeleteJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO2'));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Delete')->form();
    $client->submit($form);

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

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.position = :position');
    $query->setParameter('position', 'FOO2');
    $this->assertTrue(0 == $query->getSingleScalarResult());
}

Tests as a SafeGuard

When a job is published, you cannot edit it anymore. Even if the “Edit” link is not displayed anymore on the preview page, let’s add some tests for this requirement.

First, add another argument to the createJob() method to allow automatic publication of the job, and create a getJobByPosition() method that returns a job given its position value:

// ...

public function createJob($values = array(), $publish = false)
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array_merge(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
  ), $values));

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

    if($publish) {
      $crawler = $client->getCrawler();
      $form = $crawler->selectButton('Publish')->form();
      $client->submit($form);
      $client->followRedirect();
    }

  return $client;
}

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

    $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j WHERE j.position = :position');
    $query->setParameter('position', $position);
    $query->setMaxResults(1);
    return $query->getSingleResult();
}

If a job is published, the edit page must return a 404 status code:

// ...

public function testEditJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO3'), true);
    $crawler = $client->getCrawler();
    $crawler = $client->request('GET', sprintf('/job/%s/edit', $this->getJobByPosition('FOO3')->getToken()));
    $this->assertTrue(404 === $client->getResponse()->getStatusCode());
}

But if you run the tests, you won’t have the expected result as we forgot to implement this security measure yesterday. Writing tests is also a great way to discover bugs, as you need to think about all edge cases.

Fixing the bug is quite simple as we just need to forward to a 404 page if the job is activated:

// ...

public function editAction($token)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    if ($entity->getIsActivated()) {
        throw $this->createNotFoundException('Job is activated and cannot be edited.');
    }

  // ...
}

Back to the Future in a Test

When a job is expiring in less than five days, or if it is already expired, the user can extend the job validation for another 30 days from the current date.

Testing this requirement in a browser is not easy as the expiration date is automatically set when the job is created to 30 days in the future. So, when getting the job page, the link to extend the job is not present. Sure, you can hack the expiration date in the database, or tweak the template to always display the link, but that’s tedious and error prone. As you have already guessed, writing some tests will help us one more time.

As always, we need to add a new route for the extend method first:

# ...

ibw_job_extend:
    pattern:  /{token}/extend
    defaults: { _controller: "IbwJobeetBundle:Job:extend" }
    requirements: { _method: post }

Then, replace the Extend link code in the admin.html.twig partial with the extend form:

<!-- ... -->

{% if job.expiresSoon %}
    <form action="{{ path('ibw_job_extend', { 'token': job.token }) }}" method="post">
        {{ form_widget(extend_form) }}
        <button type="submit">Extend</button> for another 30 days
    </form>
{% endif %}

<!-- ... -->

Then, create the extend action and the extend form:

// ...

public function extendAction(Request $request, $token)
{
    $form = $this->createExtendForm($token);
    $request = $this->getRequest();

    $form->bind($request);

    if($form->isValid()) {
        $em=$this->getDoctrine()->getManager();
        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if(!$entity){
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        if(!$entity->extend()){
            throw $this->createNodFoundException('Unable to extend the Job');
        }

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

        $this->get('session')->getFlashBag()->add('notice', sprintf('Your job validity has been extended until %s', $entity->getExpiresAt()->format('m/d/Y')));
    }

    return $this->redirect($this->generateUrl('ibw_job_preview', array(
        'company' => $entity->getCompanySlug(),
        'location' => $entity->getLocationSlug(),
        'token' => $entity->getToken(),
        'position' => $entity->getPositionSlug()
    )));
}

private function createExtendForm($token)
{
    return $this->createFormBuilder(array('token' => $token))
        ->add('token', 'hidden')
        ->getForm();
}

Also, add the extend form to the preview action:

// ...

public function previewAction($token)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $deleteForm = $this->createDeleteForm($entity->getId());
    $publishForm = $this->createPublishForm($entity->getToken());
    $extendForm = $this->createExtendForm($entity->getToken());

    return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
        'entity'      => $entity,
        'delete_form' => $deleteForm->createView(),
        'publish_form' => $publishForm->createView(),
        'extend_form' => $extendForm->createView(),
    ));
}

As expected by the action, the extend() method of Job returns true if the job has been extended or false otherwise:

// ...

public function extend()
{
    if (!$this->expiresSoon())
    {
        return false;
    }

    $this->expires_at = new DateTime(date('Y-m-d H:i:s', time() + 86400 * 30));

    return true;
}

Eventually, add a test scenario:

// ...

public function testExtendJob()
{
    // A job validity cannot be extended before the job expires soon
    $client = $this->createJob(array('job[position]' => 'FOO4'), true);
    $crawler = $client->getCrawler();
    $this->assertTrue($crawler->filter('input[type=submit]:contains("Extend")')->count() == 0);

    // A job validity can be extended when the job expires soon

    // Create a new FOO5 job
    $client = $this->createJob(array('job[position]' => 'FOO5'), true);

    // Get the job and change the expire date to today
    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
    $job = $em->getRepository('IbwJobeetBundle:Job')->findOneByPosition('FOO5');
    $job->setExpiresAt(new DateTime());
    $em->flush();

    // Go to the preview page and extend the job
    $crawler = $client->request('GET', sprintf('/job/%s/%s/%s/%s', $job->getCompanySlug(), $job->getLocationSlug(), $job->getToken(), $job->getPositionSlug()));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Extend')->form();
    $client->submit($form);

    // Reload the job from db
    $job = $this->getJobByPosition('FOO5');

    // Check the expiration date
    $this->assertTrue($job->getExpiresAt()->format('y/m/d') == date('y/m/d', time() + 86400 * 30));
}

Maintenance Tasks

Even if symfony is a web framework, it comes with a command line tool. You have already used it to create the default directory structure of the application bundle and to generate various files for the model. Adding a new command is quite easy.

When a user creates a job, he must activate it to put it online. But if not, the database will grow with stale jobs. Let’s create a command that remove stale jobs from the database. This command will have to be run regularly in a cron job.

namespace IbwJobeetBundleCommand;

use SymfonyBundleFrameworkBundleCommandContainerAwareCommand;
use SymfonyComponentConsoleInputInputArgument;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleInputInputOption;
use SymfonyComponentConsoleOutputOutputInterface;
use IbwJobeetBundleEntityJob;

class JobeetCleanupCommand extends ContainerAwareCommand {

  protected function configure()
  {
      $this
          ->setName('ibw:jobeet:cleanup')
          ->setDescription('Cleanup Jobeet database')
          ->addArgument('days', InputArgument::OPTIONAL, 'The email', 90)
    ;
  }

  protected function execute(InputInterface $input, OutputInterface $output)
  {
      $days = $input->getArgument('days');

      $em = $this->getContainer()->get('doctrine')->getManager();
      $nb = $em->getRepository('IbwJobeetBundle:Job')->cleanup($days);

      $output->writeln(sprintf('Removed %d stale jobs', $nb));
  }
}

You will have to add the cleanup method to the JobRepository class:

// ...

public function cleanup($days)
{
    $query = $this->createQueryBuilder('j')
        ->delete()
        ->where('j.is_activated IS NULL')
        ->andWhere('j.created_at < :created_at')    
        ->setParameter('created_at',  date('Y-m-d', time() - 86400 * $days))
        ->getQuery();

    return $query->execute();
}

To run the command execute the following from the project folder:

php app/console ibw:jobeet:cleanup

or:

php app/console ibw:jobeet:cleanup 10

to delete stale jobs older than 10 days.
Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 10: The Forms

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Any website has forms, from the simple contact form to the complex ones with lots of fields. Writing forms is also one of the most complex and tedious task for a web developer: you need to write the HTML form, implement validation rules for each field, process the values to store them in a database, display error messages, repopulate fields in case of errors and much more …

In Day 3 of this tutorial we used the doctrine:generate:crud command to generate a simple CRUD controller for our Job entity. This also generated a Job form that you can find in /src/Ibw/JobeetBundle/Form/JobType.php file.

Customizing the Job Form

The Job form is a perfect example to learn form customization. Let’s see how to customize it, step by step.

First, change the Post a Job link in the layout to be able to check changes directly in your browser:

<a href="{{ path('ibw_job_new') }}">Post a Job</a>

Then, change the ibw_job_show route parameters in createAction of the JobController to match the new route we created in day 5 of this tutorial:

// ...

public function createAction(Request $request)
{
    $entity  = new Job();
    $form = $this->createForm(new JobType(), $entity);
    $form->bind($request);

    if ($form->isValid()) {
        $em = $this->getDoctrine()->getManager();

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

        return $this->redirect($this->generateUrl('ibw_job_show', array(
            'company' => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'id' => $entity->getId(),
            'position' => $entity->getPositionSlug()
        )));
    }

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

// ...

By default, the Doctrine generated form displays fields for all the table columns. But for the Job form, some of them must not be editable by the end user. Edit the Job form as you see below:

namespace IbwJobeetBundleForm;

use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;

class JobType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('type')
            ->add('category')
            ->add('company')
            ->add('logo')
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('token')
            ->add('is_public')
            ->add('email')
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'IbwJobeetBundleEntityJob'
        ));
    }

    public function getName()
    {
        return 'job';
    }
}

The form configuration must sometimes be more precise than what can be introspected from the database schema. For example, the email column is a varchar in the schema, but we need this column to be validated as an email. In Symfony2, validation is applied to the underlying object (e.g. Job). In other words, the question isn’t whether the form is valid, but whether or not the Job object is valid after the form has applied the submitted data to it. To do this, create a new validation.yml file in the Resources/config directory of our bundle:

IbwJobeetBundleEntityJob:
    properties:
        email:
            - NotBlank: ~
            - Email: ~

Even if the type column is also a varchar in the schema, we want its value to be restricted to a list of choices: full time, part time or freelance.

// ...
use IbwJobeetBundleEntityJob;

class JobType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true))
            // ...
    }

    // ...

}

For this to work, add the following methods in the Job entity:

     // ...

    public static function getTypes()
    {
        return array('full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance');
    }

    public static function getTypeValues()
    {
        return array_keys(self::getTypes());
    }

    // ...

The getTypes() method is used in the form to get the possible types for a Job and getTypeValues() will be used in the validation to get the valid values for the type field.

IbwJobeetBundleEntityJob:
    properties:
        type:
            - NotBlank: ~
            - Choice: { callback: getTypeValues }
        email:
            - NotBlank: ~
            - Email: ~

For each field, symfony automatically generates a label (which will be used in the rendered tag). This can be changed with the label option:

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('logo', null, array('label' => 'Company logo'))
            // ...
            ->add('how_to_apply', null, array('label' => 'How to apply?'))
            // ...
            ->add('is_public', null, array('label' => 'Public?'))
            // ...
    }

You should also add validation constraints for the rest of the fields:

IbwJobeetBundleEntityJob:
    properties:
        category:
            - NotBlank: ~
        type:
            - NotBlank: ~
            - Choice: {callback: getTypeValues}
        company:
            - NotBlank: ~
        position:
            - NotBlank: ~
        location:
            - NotBlank: ~
        description:
            - NotBlank: ~
        how_to_apply:
            - NotBlank: ~
        token:
            - NotBlank: ~
        email:
            - NotBlank: ~
            - Email: ~
        url:
            - Url: ~

The constraint applied to url field enforces the URL format to be like this: http://www.sitename.domain or https://www.sitename.domain.

After modifying validation.yml, you need to clear the cache.

Handling File Uploads in Symfony2

To handle the actual file upload in the form, we will use a virtual file field. For this, we will add a new file property to the Job entity:

    // ...

    public $file;

    // ...

Now we need to replace the logo with the file widget and change it to a file input tag:

// ...

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('file', 'file', array('label' => 'Company logo', 'required' => false))
            // ...
    }
// ...

To make sure the uploaded file is a valid image, we will use the Image validation constraint:

IbwJobeetBundleEntityJob:
    properties:
        # ...
        file:
            - Image: ~

When the form is submitted, the file field will be an instance of UploadedFile. It can be used to move the file to a permanent location. After this, we will set the job logo property to the uploaded file name.

// ...

    public function createAction(Request $request)
    {
        // ...

        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();

            $entity->file->move(__DIR__.'/../../../../web/uploads/jobs', $entity->file->getClientOriginalName());
            $entity->setLogo($entity->file->getClientOriginalName());

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

            return $this->redirect($this->generateUrl('ibw_job_show', array(
                'company' => $entity->getCompanySlug(),
                'location' => $entity->getLocationSlug(),
                'id' => $entity->getId(),
                'position' => $entity->getPositionSlug()
            )));
        }
        // ...
    }

// ...

You need to create the logo directory (web/uploads/jobs/) and check that it is writable by the web server.
Even if this implementation works, a better way is to handle the file upload using the Doctrine Job entity.

First, add the following to the Job entity:

class Job
{
    // ... 
    protected function getUploadDir()
    {
        return 'uploads/jobs';
    }

    protected function getUploadRootDir()
    {
        return __DIR__.'/../../../../web/'.$this->getUploadDir();
    }

    public function getWebPath()
    {
        return null === $this->logo ? null : $this->getUploadDir().'/'.$this->logo;
    }

    public function getAbsolutePath()
    {
        return null === $this->logo ? null : $this->getUploadRootDir().'/'.$this->logo;
    }
}

The logo property stores the relative path to the file and is persisted to the database. The getAbsolutePath() is a convenience method that returns the absolute path to the file while the getWebPath() is a convenience method that returns the web path, which can be used in a template to link to the uploaded file.

We will make the implementation so that the database operation and the moving of the file are atomic: if there is a problem persisting the entity or if the file cannot be saved, then nothing will happen. To do this, we need to move the file right as Doctrine persists the entity to the database. This can be accomplished by hooking into the Job entity lifecycle callback. Like we did in day 3 of the Jobeet tutorial, we will edit the Job.orm.yml file and add the preUpload, upload and removeUpload callbacks in it:

IbwJobeetBundleEntityJob:
    # ...

    lifecycleCallbacks:
        prePersist: [ preUpload, setCreatedAtValue, setExpiresAtValue ]
        preUpdate: [ preUpload, setUpdatedAtValue ]
        postPersist: [ upload ]
        postUpdate: [ upload ]
        postRemove: [ removeUpload ]

Now run the generate:entities doctrine command to add these new methods to the Job entity:

php app/console doctrine:generate:entities IbwJobeetBundle

Edit the Job entity and change the added methods to the following:

class Job
{
    // ...

    /**
     * @ORMPrePersist
     */
    public function preUpload()
    {
         if (null !== $this->file) {
             $this->logo = uniqid().'.'.$this->file->guessExtension();
         }
    }

    /**
     * @ORMPostPersist
     */
    public function upload()
    {
        if (null === $this->file) {
            return;
        }

        // If there is an error when moving the file, an exception will
        // be automatically thrown by move(). This will properly prevent
        // the entity from being persisted to the database on error
        $this->file->move($this->getUploadRootDir(), $this->logo);

        unset($this->file);
    }

    /**
     * @ORMPostRemove
     */
    public function removeUpload()
    {
        if(file_exists($file)) {
            if ($file = $this->getAbsolutePath()) {
                unlink($file);
            }
        }    
    }
}

The class now does everything we need: it generates a unique filename before persisting, moves the file after persisting, and removes the file if the entity is ever deleted. Now that the moving of the file is handled atomically by the entity, we should remove the code we added earlier in the controller to handle the upload:

// ...

    public function createAction(Request $request)
    {
        $entity  = new Job();
        $form = $this->createForm(new JobType(), $entity);
        $form->bind($request);

        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();

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

            return $this->redirect($this->generateUrl('ibw_job_show', array(
                'company' => $entity->getCompanySlug(),
                'location' => $entity->getLocationSlug(),
                'id' => $entity->getId(),
                'position' => $entity->getPositionSlug()
            )));
        }

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

// ...

The Form Template

Now that the form class has been customized, we need to display it. Open the new.html.twig template and edit it:

{% 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>Job creation</h1>
    <form action="{{ path('ibw_job_create') }}" method="post" {{ form_enctype(form) }}>
        <table id="job_form">
            <tfoot>
                <tr>
                    <td colspan="2">
                        <input type="submit" value="Preview your job" />
                    </td>
                </tr>
            </tfoot>
            <tbody>
                <tr>
                    <th>{{ form_label(form.category) }}</th>
                    <td>
                        {{ form_errors(form.category) }}
                        {{ form_widget(form.category) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.type) }}</th>
                    <td>
                        {{ form_errors(form.type) }}
                        {{ form_widget(form.type) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.company) }}</th>
                    <td>
                        {{ form_errors(form.company) }}
                        {{ form_widget(form.company) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.file) }}</th>
                    <td>
                        {{ form_errors(form.file) }}
                        {{ form_widget(form.file) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.url) }}</th>
                    <td>
                        {{ form_errors(form.url) }}
                        {{ form_widget(form.url) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.position) }}</th>
                    <td>
                        {{ form_errors(form.position) }}
                        {{ form_widget(form.position) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.location) }}</th>
                    <td>
                        {{ form_errors(form.location) }}
                        {{ form_widget(form.location) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.description) }}</th>
                    <td>
                        {{ form_errors(form.description) }}
                        {{ form_widget(form.description) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.how_to_apply) }}</th>
                    <td>
                        {{ form_errors(form.how_to_apply) }}
                        {{ form_widget(form.how_to_apply) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.token) }}</th>
                    <td>
                        {{ form_errors(form.token) }}
                        {{ form_widget(form.token) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.is_public) }}</th>
                    <td>
                        {{ form_errors(form.is_public) }}
                        {{ form_widget(form.is_public) }}
                        <br /> Whether the job can also be published on affiliate websites or not.
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(form.email) }}</th>
                    <td>
                        {{ form_errors(form.email) }}
                        {{ form_widget(form.email) }}
                    </td>
                </tr>
            </tbody>
        </table>
    {{ form_end(form) }}
{% endblock %}

We could render the form by just using the following line of code, but as we need more customization, we choose to render each form field by hand.

{{ form(form) }}

By printing form(form), each field in the form is rendered, along with a label and error message (if there is one). As easy as this is, it’s not very flexible (yet). Usually, you’ll want to render each form field individually so you can control how the form looks.

We also used a technique named form theming to customize how the form errors will be rendered. You can read more about this in the official Symfony2 documentation.

Do the same thing with the edit.html.twig template:

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

{% form_theme edit_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>Job edit</h1>
    <form action="{{ path('ibw_job_update', { 'id': entity.id }) }}" method="post" {{ form_enctype(edit_form) }}>
        <table id="job_form">
            <tfoot>
                <tr>
                    <td colspan="2">
                        <input type="submit" value="Preview your job" />
                    </td>
                </tr>
            </tfoot>
            <tbody>
                <tr>
                    <th>{{ form_label(edit_form.category) }}</th>
                    <td>
                        {{ form_errors(edit_form.category) }}
                        {{ form_widget(edit_form.category) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.type) }}</th>
                    <td>
                        {{ form_errors(edit_form.type) }}
                        {{ form_widget(edit_form.type) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.company) }}</th>
                    <td>
                        {{ form_errors(edit_form.company) }}
                        {{ form_widget(edit_form.company) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.file) }}</th>
                    <td>
                        {{ form_errors(edit_form.file) }}
                        {{ form(edit_form.file) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.url) }}</th>
                    <td>
                        {{ form_errors(edit_form.url) }}
                        {{ form_widget(edit_form.url) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.position) }}</th>
                    <td>
                        {{ form_errors(edit_form.position) }}
                        {{ form_widget(edit_form.position) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.location) }}</th>
                    <td>
                        {{ form_errors(edit_form.location) }}
                        {{ form_widget(edit_form.location) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.description) }}</th>
                    <td>
                        {{ form_errors(edit_form.description) }}
                        {{ form_widget(edit_form.description) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.how_to_apply) }}</th>
                    <td>
                        {{ form_errors(edit_form.how_to_apply) }}
                        {{ form_widget(edit_form.how_to_apply) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.token) }}</th>
                    <td>
                        {{ form_errors(edit_form.token) }}
                        {{ form_widget(edit_form.token) }}
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.is_public) }}</th>
                    <td>
                        {{ form_errors(edit_form.is_public) }}
                        {{ form_widget(edit_form.is_public) }}
                        <br /> Whether the job can also be published on affiliate websites or not.
                    </td>
                </tr>
                <tr>
                    <th>{{ form_label(edit_form.email) }}</th>
                    <td>
                        {{ form_errors(edit_form.email) }}
                        {{ form_widget(edit_form.email) }}
                    </td>
                </tr>
            </tbody>
        </table>
    {{ form_end(edit_form) }}
{% endblock %}

 

The Form Action

We now have a form class and a template that renders it. Now, it’s time to actually make it work with some actions. The job form is managed by four methods in the JobController:

  • newAction: Displays a blank form to create a new job
  • createAction: Processes the form (validation, form repopulation) and creates a new job with the user submitted values
  • editAction: Displays a form to edit an existing job
  • updateAction: Processes the form (validation, form repopulation) and updates an existing job with the user submitted values

When you browse to the /job/new page, a form instance for a new job object is created by calling the createForm() method and passed to the template (newAction).

When the user submits the form (createAction), the form is bound (bind($request) method) with the user submitted values and the validation is triggered.

Once the form is bound, it is possible to check its validity using the isValid() method: if the form is valid (returns true), the job is saved to the database ($em->persist($entity)), and the user is redirected to the job preview page; if not, the new.html.twig template is displayed again with the user submitted values and the associated error messages.

The modification of an existing job is quite similar. The only difference between the new and the edit action is that the job object to be modified is passed as the second argument of the createForm method. This object will be used for default widget values in the template.

You can also define default values for the creation form. For this we will pass a pre-modified Job object to the createForm() method to set the type default value to full-time:

    // ...

    public function newAction()
    {
        $entity = new Job();
        $entity->setType('full-time');
        $form = $this->createForm(new JobType(), $entity);

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

    // ...

PROTECTING THE JOB FORM WITH A TOKEN

Everything must work fine by now. As of now, the user must enter the token for the job. But the job token must be generated automatically when a new job is created, as we don’t want to rely on the user to provide a unique token. Add the setTokenValue method to the prePersist lifecycleCallbacks for the Job entity:

# ...

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

Regenerate the doctrine entities to apply this modification:

php app/console doctrine:generate:entities IbwJobeetBundle

Edit the setTokenValue() method of the Job entity to add the logic that generates the token before a new job is saved:

    // ...

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

    // ...

You can now remove the token field from the form:

// ...

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('category')
            ->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true))
            ->add('company')
            ->add('file', 'file', array('label' => 'Company logo', 'required' => false))
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply', null, array('label' => 'How to apply?'))
            ->add('is_public', null, array('label' => 'Public?'))
            ->add('email')
        ;
    }

// ...

Remove it from the new.html.twig and edit.html.twig templates also:

<!-- ... -->
<tr>
    <th>{{ form_label(form.token) }}</th>
    <td>
        {{ form_errors(form.token) }}
        {{ form_widget(form.token) }}
    </td>
</tr>
<!-- ... -->
<!-- ... -->
<tr>
    <th>{{ form_label(edit_form.token) }}</th>
    <td>
        {{ form_errors(edit_form.token) }}
        {{ form(edit_form.token) }}
    </td>
</tr>
<!-- ... -->

And from the validation.yml file:

# ...
    # ...
    token:
        - NotBlank: ~

If you remember the user stories from day 2, a job can be edited only if the user knows the associated token. Right now, it is pretty easy to edit or delete any job, just by guessing the URL. That’s because the edit URL is like /job/ID/edit, where ID is the primary key of the job.

Let’s change the routes so you can edit or delete a job only if you now the secret token:

# ...

ibw_job_edit:
    pattern:  /{token}/edit
    defaults: { _controller: "IbwJobeetBundle:Job:edit" }

ibw_job_update:
    pattern:  /{token}/update
    defaults: { _controller: "IbwJobeetBundle:Job:update" }
    requirements: { _method: post|put }

ibw_job_delete:
    pattern:  /{token}/delete
    defaults: { _controller: "IbwJobeetBundle:Job:delete" }
    requirements: { _method: post|delete }

Now edit the JobController to use the token instead of the id:

// ...
class JobController extends Controller
{
    // ...

    public function editAction($token)
    {
        $em = $this->getDoctrine()->getManager();

        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if (!$entity) {
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        $editForm = $this->createForm(new JobType(), $entity);
        $deleteForm = $this->createDeleteForm($token);

        return $this->render('IbwJobeetBundle:Job:edit.html.twig', array(
            'entity'      => $entity,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        ));
    }   

    public function updateAction(Request $request, $token)
    {
        $em = $this->getDoctrine()->getManager();

        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if (!$entity) {
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        $editForm   = $this->createForm(new JobType(), $entity);
        $deleteForm = $this->createDeleteForm($token);

        $editForm->bind($request);

        if ($editForm->isValid()) {
            $em->persist($entity);
            $em->flush();

            return $this->redirect($this->generateUrl('ibw_job_edit', array('token' => $token)));
        }

        return $this->render('IbwJobeetBundle:Job:edit.html.twig', array(
            'entity'      => $entity,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        ));
    }

    public function deleteAction(Request $request, $token)
    {
        $form = $this->createDeleteForm($token);
        $form->bind($request);

        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

            if (!$entity) {
                throw $this->createNotFoundException('Unable to find Job entity.');
            }

            $em->remove($entity);
            $em->flush();
        }

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

    /**
     * Creates a form to delete a Job entity by id.
     *
     * @param mixed $id The entity id
     *
     * @return SymfonyComponentFormForm The form
     */
    private function createDeleteForm($token)
    {
        return $this->createFormBuilder(array('token' => $token))
            ->add('token', 'hidden')
            ->getForm()
        ;
    }
}

In the job show template show.html.twig, change the ibw_job_edit route parameter:

<a href="{{ path('ibw_job_edit', {'token': entity.token}) }}">

Do the same for ibw_job_update route in edit.html.twig job template:

<form action="{{ path('ibw_job_update', {'token': entity.token}) }}" method="post" {{ form_enctype(edit_form) }}>

Now, all routes related to the jobs, except the job_show_user one, embed the token. For instance, the route to edit a job is now of the following pattern:
http://jobeet.local/job/TOKEN/edit

The Preview Page

The preview page is the same as the job page display. The only difference is that the job preview page will be accessed using the job token instead of the job id:

# ...

ibw_job_show:
    pattern:  /{company}/{location}/{id}/{position}
    defaults: { _controller: "IbwJobeetBundle:Job:show" }
    requirements:
        id:  d+

ibw_job_preview:
    pattern:  /{company}/{location}/{token}/{position}
    defaults: { _controller: "IbwJobeetBundle:Job:preview" }
    requirements:
        token:  w+

# ...

The preview action (here the difference from the show action is that the job is retrieved from the database using the provided token instead of the id):

// ...

    public function previewAction($token)
    {
        $em = $this->getDoctrine()->getManager();

        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if (!$entity) {
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        $deleteForm = $this->createDeleteForm($entity->getId());

        return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
            'entity'      => $entity,
            'delete_form' => $deleteForm->createView(),
        ));
    }

// ...

If the user comes in with the tokenized URL, we will add an admin bar at the top. At the beginning of the show.html.twig template, include a template to host the admin bar and remove the edit link at the bottom:

<!-- ... -->

{% block content %}
    {% if app.request.get('token') %}
        {% include 'IbwJobeetBundle:Job:admin.html.twig' with {'job': entity} %}
    {% endif %}

 <!-- ... -->

{% endblock %}

Then, create the admin.html.twig template:

<div id="job_actions">
    <h3>Admin</h3>
    <ul>
        {% if not job.isActivated %}
            <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Edit</a></li>
            <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Publish</a></li>
        {% endif %}
        <li>
            <form action="{{ path('ibw_job_delete', { 'token': job.token }) }}" method="post">
                {{ form_widget(delete_form) }}
                <button type="submit" onclick="if(!confirm('Are you sure?')) { return false; }">Delete</button>
            </form>
        </li>
        {% if job.isActivated %}
            <li {% if job.expiresSoon %} class="expires_soon" {% endif %}>
                {% if job.isExpired %}
                    Expired
                {% else %}
                    Expires in <strong>{{ job.getDaysBeforeExpires }}</strong> days
                {% endif %}

                {% if job.expiresSoon %}
                    - <a href="">Extend</a> for another 30 days
                {% endif %}
            </li>
        {% else %}
            <li>
                [Bookmark this <a href="{{ url('ibw_job_preview', { 'token': job.token, 'company': job.companyslug, 'location': job.locationslug, 'position': job.positionslug }) }}">URL</a> to manage this job in the future.]
            </li>
        {% endif %}
    </ul>
</div>

There is a lot of code, but most of the code is simple to understand.

To make the template more readable, we have added a bunch of shortcut methods in the Job entity class:

    // ...

    public function isExpired()
    {
        return $this->getDaysBeforeExpires() < 0;
    }

    public function expiresSoon()
    {
        return $this->getDaysBeforeExpires() < 5;    
    }

    public function getDaysBeforeExpires()
    {
        return ceil(($this->getExpiresAt()->format('U') - time()) / 86400);
    }

    // ...

The admin bar displays the different actions depending on the job status:

 

We will now redirect the create and update actions of the JobController to the new preview page:

public function createAction(Request $request)
{
    // ...
    if ($form->isValid()) {
        // ... 
        return $this->redirect($this->generateUrl('ibw_job_preview', array(
            'company' => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'token' => $entity->getToken(),
            'position' => $entity->getPositionSlug()
        )));
    }
    // ...
}

public function updateAction(Request $request, $token)
{
    // ...
    if ($editForm->isValid()) {
        // ... 

        return $this->redirect($this->generateUrl('ibw_job_preview', array(
            'company' => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'token' => $entity->getToken(), 
            'position' => $entity->getPositionSlug()
        )));
    }
    // ...
}

As we said before, you can edit a job only if you know the job token and you’re the admin of the site. At the moment, when you access a job page, you will see the Edit link and that’s bad. Let’s remove it from the show.html.twig file:

<div style="padding: 20px 0">
    <a href="{{ path('ibw_job_edit', { 'token': entity.token }) }}">
        Edit
    </a>
</div>

Job Activation and Publication

In the previous section, there is a link to publish the job. The link needs to be changed to point to a new publish action. For this we will create new route:

# ...

ibw_job_publish:
    pattern:  /{token}/publish
    defaults: { _controller: "IbwJobeetBundle:Job:publish" }
    requirements: { _method: post }

We can now change the link of the Publish link (we will use a form here, like when deleting a job, so we will have a POST request):

<!-- ... -->

{% if not job.isActivated %}
    <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Edit</a></li>
    <li>
        <form action="{{ path('ibw_job_publish', { 'token': job.token }) }}" method="post">
            {{ form_widget(publish_form) }}
            <button type="submit">Publish</button>
        </form>
    </li>
{% endif %}

<!-- ... -->

The last step is to create the publish action, the publish form and to edit the preview action to send the publish form to the template:

// ...

public function previewAction($token)
{
    // ...

    $deleteForm = $this->createDeleteForm($entity->getToken());
    $publishForm = $this->createPublishForm($entity->getToken());

    return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
        'entity'      => $entity,
        'delete_form' => $deleteForm->createView(),
        'publish_form' => $publishForm->createView(),
    ));
}

public function publishAction(Request $request, $token)
{
    $form = $this->createPublishForm($token);
    $form->bind($request);

    if ($form->isValid()) {
        $em = $this->getDoctrine()->getManager();
        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if (!$entity) {
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        $entity->publish();
        $em->persist($entity);
        $em->flush();

        $this->get('session')->getFlashBag()->add('notice', 'Your job is now online for 30 days.');
    }

    return $this->redirect($this->generateUrl('ibw_job_preview', array(
        'company' => $entity->getCompanySlug(),
        'location' => $entity->getLocationSlug(),
        'token' => $entity->getToken(),
        'position' => $entity->getPositionSlug()
    )));
}

private function createPublishForm($token)
{
    return $this->createFormBuilder(array('token' => $token))
        ->add('token', 'hidden')
        ->getForm()
    ;
}

// ...

The publishAction() method uses a new publish() method that can be defined as follows:

// ...

public function publish()
{
    $this->setIsActivated(true);
}

// ...

You can now test the new publish feature in your browser.

But we still have something to fix. The non-activated jobs must not be accessible, which means that they must not show up on the Jobeet homepage, and must not be accessible by their URL. We need to edit the JobRepository methods to add this requirement:

namespace IbwJobeetBundleRepository;
use DoctrineORMEntityRepository;

class JobRepository extends EntityRepository
{
    public function getActiveJobs($category_id = null, $max = null, $offset = 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);
        }

        $query = $qb->getQuery();

        return $query->getResult();
    }

    public function countActiveJobs($category_id = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->select('count(j.id)')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->andWhere('j.is_activated = :activated')
            ->setParameter('activated', 1);

        if($category_id) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        $query = $qb->getQuery();

        return $query->getSingleScalarResult();
    }

    public function getActiveJob($id)
    {
        $query = $this->createQueryBuilder('j')
            ->where('j.id = :id')
            ->setParameter('id', $id)
            ->andWhere('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->andWhere('j.is_activated = :activated')
            ->setParameter('activated', 1)
            ->setMaxResults(1)
            ->getQuery();

        try {
            $job = $query->getSingleResult();
        } catch (DoctrineOrmNoResultException $e) {
        $job = null;
          }

        return $job;
    }
}

The same for CategoryRepository getWithJobs() method:

namespace IbwJobeetBundleRepository;
use DoctrineORMEntityRepository;

class CategoryRepository extends EntityRepository
{
    public function getWithJobs()
    {
        $query = $this->getEntityManager()
            ->createQuery('SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date AND j.is_activated = :activated')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->setParameter('activated', 1);

        return $query->getResult();
    }
}

That’s all. You can test it now in your browser. All non-activated jobs have disappeared from the homepage; even if you know their URLs, they are not accessible anymore. They are, however, accessible if one knows the job’s token URL. In that case, the job preview will show up with the admin bar.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 9: The Functional Tests

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Functional tests are a great tool to test your application from end to end: from the request made by a browser to the response sent by the server. They test all the layers of an application: the routing, the model, the actions and the templates. They are very similar to what you probably already do manually: each time you add or modify an action, you need to go to the browser and check that everything works as expected by clicking on links and checking elements on the rendered page. In other words, you run a scenario corresponding to the use case you have just implemented.

As the process is manual, it is tedious and error prone. Each time you change something in your code, you must step through all the scenarios to ensure that you did not break something. That’s insane. Functional tests in symfony provide a way to easily describe scenarios. Each scenario can then be played automatically over and over again by simulating the experience a user has in a browser. Like unit tests, they give you the confidence to code in peace.

Functional tests have a very specific workflow:

  • Make a request;
  • Test the response;
  • Click on a link or submit a form;
  • Test the response;
  • Rinse and repeat;

Our First Functional Test

Functional tests are simple PHP files that typically live in the Tests/Controller directory of your bundle. If you want to test the pages handled by your CategoryController class, start by creating a new CategoryControllerTest class that extends a special WebTestCase class:

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class CategoryControllerTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testShow()
    {
        $client = static::createClient();

        $crawler = $client->request('GET', '/category/index');
        $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue(200 === $client->getResponse()->getStatusCode());
    }
}

To learn more about crawler, read the Symfony documentation here.

Running Functional Tests

As for unit tests, launching functional tests can be done by executing the phpunit command:

phpunit -c app/ src/Ibw/JobeetBundle/Tests/Controller/CategoryControllerTest

This test will fail because the tested url, /category/index, is not a valid url in Jobeet:

PHPUnit 3.7.22 by Sebastian Bergmann.

Configuration read from /var/www/jobeet/app/phpunit.xml.dist

F

Time: 2 seconds, Memory: 25.25Mb

There was 1 failure:

1) IbwJobeetBundleTestsControllerCategoryControllerTest::testShow
Failed asserting that false is true.

Writing Functional Tests

Writing functional tests is like playing a scenario in a browser. We already have written all the scenarios we need to test as part of the day 2 stories.

First, let’s test the Jobeet homepage by editing the JobControllerTest class. Replace the code with the following one:

EXPIRED JOBS ARE NOT LISTED

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class JobControllerTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

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

        $this->assertEquals('IbwJobeetBundleControllerJobController::indexAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0);
    }
}

To verify the exclusion of expired jobs from the homepage, we check that the CSS selector .jobs td.position:contains("Expired") does not match anywhere in the response HTML content (remember that in the fixtures, the only expired job we have contains “Expired” in the position).

ONLY N JOBS ARE LISTED FOR A CATEGORY

Add the following code at the end of your testIndex() function. To get the custom parameter defined in app/config/config.yml in our functional test, we will use the kernel:

public function testIndex()
{
    //...
    $kernel = static::createKernel();
    $kernel->boot();
    $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');
    $this->assertTrue($crawler->filter('.category_programming tr')->count() <= $max_jobs_on_homepage );
}

For this test to work we will need to add the corresponding CSS class to each category in the Job/index.html.twig file (so we can select each category and count the jobs listed) :

<!-- ... -->

    {% for category in categories %}
        <div class="category_{{ category.slug }}">
           <div class="category">
<!-- ... -->

A CATEGORY HAS A LINK TO THE CATEGORY PAGE ONLY IF TOO MANY JOBS

public function testIndex()
{
    //...
    $this->assertTrue($crawler->filter('.category_design .more_jobs')->count() == 0);
    $this->assertTrue($crawler->filter('.category_programming .more_jobs')->count() == 1);
}

In these tests, we check that there is no “more jobs” link for the design category (.category_design .more_jobs does not exist), and that there is a “more jobs” link for the programming category (.category_programming .more_jobs does exist).

JOBS ARE SORTED BY DATE

To test if jobs are actually sorted by date, we need to check that the first job listed on the homepage is the one we expect. This can be done by checking that the URL contains the expected primary key. As the primary key can change between runs, we need to get the Doctrine object from the database first.

public function testIndex()
{    
    // ...
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

    $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC');
    $query->setParameter('slug', 'programming');
    $query->setParameter('date', date('Y-m-d H:i:s', time()));
    $query->setMaxResults(1);
    $job = $query->getSingleResult();

    $this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $job->getId()))->count() == 1);
}

Even if the test works in this very moment, we need to refactor the code a bit, as getting the first job of the programming category can be reused elsewhere in our tests. We won’t move the code to the Model layer as the code is test specific. Instead, we will move the code to the getMostRecentProgrammingJob function in our test class:

// ...

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

        $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC');
        $query->setParameter('slug', 'programming');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);

        return $query->getSingleResult();
    }

// ...

You can now replace the previous test code by the following one:

// ...

$this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $this->getMostRecentProgrammingJob()->getId()))->count() == 1);

//...

EACH JOB ON THE HOMEPAGE IS CLICKABLE

To test the job link on the homepage, we simulate a click on the “Web Developer” text. As there are many of them on the page, we have explicitly to ask the browser to click on the first one.

Each request parameter is then tested to ensure that the routing has done its job correctly.

public function testIndex() 
{
    // ...

    $job = $this->getMostRecentProgrammingJob();
    $link = $crawler->selectLink('Web Developer')->first()->link();
    $crawler = $client->click($link);
    $this->assertEquals('IbwJobeetBundleControllerJobController::showAction', $client->getRequest()->attributes->get('_controller'));
    $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company'));
    $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location'));
    $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position'));
    $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id'));
}

// ...

LEARN BY THE EXAMPLE

In this section, you have all the code needed to test the job and category pages. Read the code carefully as you may learn some new neat tricks:

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class JobControllerTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

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

        $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC');
        $query->setParameter('slug', 'programming');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);

        return $query->getSingleResult();
    }

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

        $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j WHERE j.expires_at < :date');             
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);

        return $query->getSingleResult();
    }

    public function testIndex()
    {
        // get the custom parameters from app config.yml
        $kernel = static::createKernel();
        $kernel->boot();
        $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');

        $client = static::createClient();

        $crawler = $client->request('GET', '/');
        $this->assertEquals('IbwJobeetBundleControllerJobController::indexAction', $client->getRequest()->attributes->get('_controller'));

        // expired jobs are not listed
        $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0);

        // only $max_jobs_on_homepage jobs are listed for a category
        $this->assertTrue($crawler->filter('.category_programming tr')->count()<= $max_jobs_on_homepage); 
        $this->assertTrue($crawler->filter('.category_design .more_jobs')->count() == 0);
        $this->assertTrue($crawler->filter('.category_programming .more_jobs')->count() == 1);

        // jobs are sorted by date
        $this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $this->getMostRecentProgrammingJob()->getId()))->count() == 1);

        // each job on the homepage is clickable and give detailed information
        $job = $this->getMostRecentProgrammingJob();
        $link = $crawler->selectLink('Web Developer')->first()->link();
        $crawler = $client->click($link);
        $this->assertEquals('IbwJobeetBundleControllerJobController::showAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company'));
        $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location'));
        $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position'));
        $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id'));

        // a non-existent job forwards the user to a 404
        $crawler = $client->request('GET', '/job/foo-inc/milano-italy/0/painter');
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());

        // an expired job page forwards the user to a 404
        $crawler = $client->request('GET', sprintf('/job/sensio-labs/paris-france/%d/web-developer', $this->getExpiredJob()->getId()));
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
    }
}

 

namespace IbwJobeetBundleTestsController;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class CategoryControllerTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

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

        // get the custom parameters from app/config.yml
        $max_jobs_on_category = $kernel->getContainer()->getParameter('max_jobs_on_category');
        $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');

        $client = static::createClient();

        $categories = $this->em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        // categories on homepage are clickable
        foreach($categories as $category) {
            $crawler = $client->request('GET', '/');

            $link = $crawler->selectLink($category->getName())->link();
            $crawler = $client->click($link);

            $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
            $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug'));

            $jobs_no = $this->em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId()); 

            // categories with more than $max_jobs_on_homepage jobs also have a "more" link                 
            if($jobs_no > $max_jobs_on_homepage) {
                $crawler = $client->request('GET', '/');
                $link = $crawler->filter(".category_" . $category->getSlug() . " .more_jobs a")->link();
                $crawler = $client->click($link);

                $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
                $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug'));
            }

            $pages = ceil($jobs_no/$max_jobs_on_category);

            // only $max_jobs_on_category jobs are listed 
            $this->assertTrue($crawler->filter('.jobs tr')->count() <= $max_jobs_on_category);
            $this->assertRegExp("/" . $jobs_no . " jobs/", $crawler->filter('.pagination_desc')->text());

            if($pages > 1) {
                $this->assertRegExp("/page 1/" . $pages . "/", $crawler->filter('.pagination_desc')->text());

                for ($i = 2; $i <= $pages; $i++) {
                    $link = $crawler->selectLink($i)->link();
                    $crawler = $client->click($link);

                    $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
                    $this->assertEquals($i, $client->getRequest()->attributes->get('page'));
                    $this->assertTrue($crawler->filter('.jobs tr')->count() <= $max_jobs_on_category);
                    if($jobs_no >1) {
                        $this->assertRegExp("/" . $jobs_no . " jobs/", $crawler->filter('.pagination_desc')->text());
                    }
                    $this->assertRegExp("/page " . $i . "/" . $pages . "/", $crawler->filter('.pagination_desc')->text());
                }
            }     
        }
    }
}

That’s all for today! Tomorrow, we will learn all there is to know about forms.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 8: The Unit Tests

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

Tests in Symfony

There are two different kinds of automated tests in Symfony: unit tests and functional tests. Unit tests verify that each method and function is working properly. Each test must be as independent as possible from the others. On the other hand, functional tests verify that the resulting application behaves correctly as a whole.

Unit tests will be covered in this post, whereas the next post will be dedicated to funcional tests.

Symfony2 integrates with an independent library, the PHPUnit, to give you a rich testing framework. To run tests, you will have to install PHPUnit 3.5.11 or later.

If you don’t have PHPUnit installed, use the following to get it:

sudo apt-get install phpunit
sudo pear channel-discover pear.phpunit.de
sudo pear channel-discover pear.symfony-project.com
sudo pear channel-discover components.ez.no
sudo pear channel-discover pear.symfony.com
sudo pear update-channels
sudo pear upgrade-all
sudo pear install pear.symfony.com/Yaml
sudo pear install --alldeps phpunit/PHPUnit
sudo pear install --force --alldeps phpunit/PHPUnit

Each test – whether it’s a unit test or a functional test – is a PHP class that should live in the Tests/ subdirectory of your bundles. If you follow this rule, then you can run all of your application’s tests with the following command:

phpunit -c app/

The -c option tells PHPUnit to look in the app/ directory for a configuration file. If you’re curious about the PHPUnit options, check out the app/phpunit.xml.dist file.

A unit test is usually a test against a specific PHP class. Let’s start by writing tests for the Jobeet:slugify() method.

Create a new file, JobeetTest.php, in the src/Ibw/JobeetBundle/Tests/Utils folder. By convention, the Tests/ subdirectory should replicate the directory of your bundle. So, when we are testing a class in our bundle’s Utils/ directory, we put the test in the Tests/Utils/ directory:

namespace IbwJobeetBundleTestsUtils;

use IbwJobeetBundleUtilsJobeet;

class JobeetTest extends PHPUnit_Framework_TestCase
{
    public function testSlugify()
    {
        $this-&gt;assertEquals('sensio', Jobeet::slugify('Sensio'));
        $this-&gt;assertEquals('sensio-labs', Jobeet::slugify('sensio labs'));
        $this-&gt;assertEquals('sensio-labs', Jobeet::slugify('sensio labs'));
        $this-&gt;assertEquals('paris-france', Jobeet::slugify('paris,france'));
        $this-&gt;assertEquals('sensio', Jobeet::slugify(' sensio'));
        $this-&gt;assertEquals('sensio', Jobeet::slugify('sensio '));
    }
}

To run only this test, you can use the following command:

phpunit -c app/ src/Ibw/JobeetBundle/Tests/Utils/JobeetTest

As everything should work fine, you should get the following result:

PHPUnit 3.7.22 by Sebastian Bergmann.

Configuration read from /var/www/jobeet/app/phpunit.xml.dist

.

Time: 0 seconds, Memory: 8.00Mb

OK (1 test, 6 assertions)

For a full list of assertions, you can check the PHPUnit documentation.

Adding Tests for new Features

The slug for an empty string is an empty string. You can test it, it will work. But an empty string in a URL is not that a great idea. Let’s change the slugify() method so that it returns the “n-a” string in case of an empty string.

You can write the test first, then update the method, or the other way around. It is really a matter of taste, but writing the test first gives you the confidence that your code actually implements what you planned:

// ...

$this-&gt;assertEquals('n-a', Jobeet::slugify(''));

// ...

Now, if we run the test again, we will have a failure:

PHPUnit 3.7.22 by Sebastian Bergmann.

Configuration read from /var/www/jobeet/app/phpunit.xml.dist

F

Time: 0 seconds, Memory: 8.25Mb

There was 1 failure:

1) IbwJobeetBundleTestsUtilsJobeetTest::testSlugify
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'n-a'
+''

/var/www/jobeet/src/Ibw/JobeetBundle/Tests/Utils/JobeetTest.php:13

FAILURES!
Tests: 1, Assertions: 5, Failures: 1.

Now, edit the Jobeet::slugify method and add the following condition at the beginning:

// ...

    static public function slugify($text)
    {
        if (empty($text)) {
            return 'n-a';
        }

        // ...
    }

The test must now pass as expected, and you can enjoy the green bar.

Adding Tests because of a Bug

Let’s say that time has passed and one of your users reports a weird bug: some job links point to a 404 error page. After some investigation, you find that for some reason, these jobs have an empty company, position, or location slug.

How is it possible?

You look through the records in the database and the columns are definitely not empty. You think about it for a while, and bingo, you find the cause. When a string only contains non-ASCII characters, the slugify() method converts it to an empty string. So happy to have found the cause, you open the Jobeet class and fix the problem right away. That’s a bad idea. First, let’s add a test:

$this->assertEquals('n-a', Jobeet::slugify(' - '));

After checking that the test does not pass, edit the Jobeet class and move the empty string check to the end of the method:

static public function slugify($text)
{
    // ...

    if (empty($text))
    {
        return 'n-a';
    }

    return $text;
}

The new test now passes, as do all the other ones. The slugify() had a bug despite our 100% coverage.

You cannot think about all edge cases when writing tests, and that’s fine. But when you discover one, you need to write a test for it before fixing your code. It also means that your code will get better over time, which is always a good thing.

Towards a better slugify Method

You probably know that symfony has been created by French people, so let’s add a test with a French word that contains an “accent”:

$this->assertEquals('developpeur-web', Jobeet::slugify('Développeur Web'));

The test must fail. Instead of replacing é by e, the slugify() method has replaced it by a dash (-). That’s a tough problem, called transliteration. Hopefully, if you have iconv Library installed, it will do the job for us. Replace the code of the slugify method with the following:

The test must fail. Instead of replacing é by e, the slugify() method has replaced it by a dash (-). That’s a tough problem, called transliteration. Hopefully, if you have iconv Library installed, it will do the job for us. Replace the code of the slugify method with the following:

static public function slugify($text)
{
    // replace non letter or digits by -
    $text = preg_replace('#[^\pLd]+#u', '-', $text);

    // trim
    $text = trim($text, '-');

    // transliterate
    if (function_exists('iconv'))
    {
        $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
    }

    // lowercase
    $text = strtolower($text);

    // remove unwanted characters
    $text = preg_replace('#[^-w]+#', '', $text);

    if (empty($text))
    {
        return 'n-a';
    }

    return $text;
}

Remember to save all your PHP files with the UTF-8 encoding, as this is the default Symfony encoding, and the one used by iconv to do the transliteration.

Also change the test file to run the test only if iconv is available:

if (function_exists('iconv')) {
    $this-&gt;assertEquals('developpeur-web', Jobeet::slugify('Développeur Web'));
}

Code Coverage

When you write tests, it is easy to forget a portion of the code. If you add a new feature or you just want to verify your code coverage statistics, all you need to do is to check the code coverage by using the --coverage-html option:

phpunit --coverage-html=web/cov/ -c app/
Check the code coverage by opening the generated http://jobeet.local/cov/index.html page in a browser.

The code coverage only works if you have XDebug enabled and all dependencies installed.

sudo apt-get install php5-xdebug

Your cov/index.html should look like this:

day 8 - code coverage1

Keep in mind that when this indicates that your code is fully unit tested, it just means that each line has been executed, not that all the edge cases have been tested.

Doctrine Unit Tests

Unit testing a Doctrine model class is a bit more complex as it requires a database connection. You already have the one you use for your development, but it is a good habit to create a dedicated database for tests.

At the beginning of this tutorial, we introduced the environments as a way to vary an application’s settings. By default, all symfony tests are run in the test environment, so let’s configure a different database for the test environment:

Go to your app/config directory and create a copy of parameters.yml file, called parameters_test.yml. Open parameters_test.yml and change the name of your database to jobeet_test. For this to be imported, we have to add it in the config_test.yml file :

imports:
    - { resource: config_dev.yml }
    - { resource: parameters_test.yml }
// ...

Testing the Job Entity

First, we need to create the JobTest.php file in the Tests/Entity folder.

The setUp function will manipulate your database each time you will run the test. At first, it will drop your current database, then it will re-create it and load data from fixtures in it. This will help you have the same initial data in the database you created for the test environment before running the tests.

namespace IbwJobeetBundleEntity;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use IbwJobeetBundleUtilsJobeet as Jobeet;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class JobTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testGetCompanySlug()
    {
        $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ')
            ->setMaxResults(1)
            ->getSingleResult();

        $this->assertEquals($job->getCompanySlug(), Jobeet::slugify($job->getCompany()));
    }

    public function testGetPositionSlug()
    {
        $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ')
            ->setMaxResults(1)
            ->getSingleResult();

        $this->assertEquals($job->getPositionSlug(), Jobeet::slugify($job->getPosition()));
    }

    public function testGetLocationSlug()
    {
        $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ')
            ->setMaxResults(1)
            ->getSingleResult();

        $this->assertEquals($job->getLocationSlug(), Jobeet::slugify($job->getLocation()));
    }

    public function testSetExpiresAtValue()
    {
        $job = new Job();
        $job->setExpiresAtValue();

        $this->assertEquals(time() + 86400 * 30, $job->getExpiresAt()->format('U'));
    }

    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

Testing the Repository Classes

Now, let’s write some tests for the JobRepository class, to see if the functions we created in the previous days are returning the right values:

namespace IbwJobeetBundleTestsRepository;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class JobRepositoryTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testCountActiveJobs()
    {
        $query = $this->em->createQuery('SELECT c FROM IbwJobeetBundle:Category c');
        $categories = $query->getResult();

        foreach($categories as $category) {
            $query = $this->em->createQuery('SELECT COUNT(j.id) FROM IbwJobeetBundle:Job j WHERE j.category = :category AND j.expires_at > :date');
            $query->setParameter('category', $category->getId());
            $query->setParameter('date', date('Y-m-d H:i:s', time()));
            $jobs_db = $query->getSingleScalarResult();

            $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId());
            // This test will verify if the value returned by the countActiveJobs() function
            // coincides with the number of active jobs for a given category from the database
            $this->assertEquals($jobs_rep, $jobs_db);
        }
    }

    public function testGetActiveJobs()
    {
        $query = $this->em->createQuery('SELECT c from IbwJobeetBundle:Category c');
        $categories = $query->getResult();

        foreach ($categories as $category) {
            $query = $this->em->createQuery('SELECT COUNT(j.id) from IbwJobeetBundle:Job j WHERE j.expires_at > :date AND j.category = :category');
            $query->setParameter('date', date('Y-m-d H:i:s', time()));
            $query->setParameter('category', $category->getId());
            $jobs_db = $query->getSingleScalarResult();

            $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), null, null);
            // This test tells if the number of active jobs for a given category from
            // the database is the same as the value returned by the function
            $this->assertEquals($jobs_db, count($jobs_rep));
        }
    }

    public function testGetActiveJob()
    {
        $query = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at > :date');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);
        $job_db = $query->getSingleResult();

        $job_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJob($job_db->getId());
        // If the job is active, the getActiveJob() method should return a non-null value
        $this->assertNotNull($job_rep);

        $query = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at < :date');         $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);
        $job_expired = $query->getSingleResult();

        $job_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJob($job_expired->getId());
        // If the job is expired, the getActiveJob() method should return a null value
        $this->assertNull($job_rep);
    }

    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

We will do the same thing for CategoryRepository class:

namespace IbwJobeetBundleTestsRepository;

use SymfonyBundleFrameworkBundleTestWebTestCase;
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleOutputNullOutput;
use SymfonyComponentConsoleInputArrayInput;
use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand;
use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand;

class CategoryRepositoryTest 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 SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM'));
        $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em);
        $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }

    public function testGetWithJobs()
    {
        $query = $this->em->createQuery('SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $categories_db = $query->getResult();

        $categories_rep = $this->em->getRepository('IbwJobeetBundle:Category')->getWithJobs();
        // This test verifies if the number of categories having active jobs, returned
        // by the getWithJobs() function equals the number of categories having active jobs from database
        $this->assertEquals(count($categories_rep), count($categories_db));
    }

    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

After you finish writing the tests, run them with the following command, in order to generate the code coverage percent for the whole functions :

phpunit --coverage-html=web/cov/ -c app src/Ibw/JobeetBundle/Tests/Repository/

Now, if you go to http://jobeet.local/cov/Repository.html you will see that the code coverage for Repository Tests is not 100% complete.

Day 8 - coverage not complete

Let’s add some tests for the JobRepository to achieve 100% code coverage. At the moment, in our database, we have two job categories having 0 active jobs and one job category having just one active job. That why, when we will test the $max and $offset parameters, we will run the following tests just on the categories with at least 3 active jobs. In order to do that, add this inside your foreach statement, from your testGetActiveJobs() function:

// ...
foreach ($categories as $category) {
    // ...

    // If there are at least 3 active jobs in the selected category, we will
    // test the getActiveJobs() method using the limit and offset parameters too
    // to get 100% code coverage
    if($jobs_db > 2 ) {
        $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 2);
        // This test tells if the number of returned active jobs is the one $max parameter requires
        $this->assertEquals(2, count($jobs_rep));

        $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 2, 1);
        // We set the limit to 2 results, starting from the second job and test if the result is as expected
        $this->assertEquals(2, count($jobs_rep));
    }
}
// ...

Run the code coverage command again :

phpunit --coverage-html=web/cov/ -c app src/Ibw/JobeetBundle/Tests/Repository/

This time, if you check your code coverage, you will see that it 100% complete.

Day 8 - coverage complete

That’s all for today! See you tomorrow, when we will talk about functional tests.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 7: Playing With the Category Page

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Today we will make the Category page like it is described in the second day’s requirements:

“The user sees a list of all the jobs from the category sorted by date and paginated with 20 jobs per page“

The Category Route

First, we need to add a route to define a pretty URL for the category page. Add it at the beginning of the routing file:

# ...
IbwJobeetBundle_category:
    pattern:  /category/{slug}
    defaults: { _controller: IbwJobeetBundle:Category:show }

To get the slug of a category we need to add the getSlug() method to our category class:

use IbwJobeetBundleUtilsJobeet as Jobeet;

class Category
{
    // ...

    public function getSlug()
    {
        return Jobeet::slugify($this->getName());
    }
}

The Category Link

Now, edit the index.html.twig template of the job controller to add the link to the category page:

<!-- some HTML code -->

                    <h1><a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug }) }}">{{ category.name }}</a></h1>

<!-- some HTML code -->

                </table>

                {% if category.morejobs %}
                    <div class="more_jobs">
                        and <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug }) }}">{{ category.morejobs }}</a>
                        more...
                    </div>
                {% endif %}
            </div>
        {% endfor %}
    </div>
{% endblock %}

In the template above we used category.morejobs, so let’s define it:

class Category
{
    // ...

    private $more_jobs;

    // ...

    public function setMoreJobs($jobs)
    {
        $this-&gt;more_jobs = $jobs &gt;=  0 ? $jobs : 0;
    }

    public function getMoreJobs()
    {
        return $this-&gt;more_jobs;
    }
}

The more_jobs property will hold the number of active jobs for the category minus the number of jobs listed on the homepage. Now, in JobController, we need to set the more_jobs value for each category:

public function indexAction()
{
    $em = $this-&gt;getDoctrine()-&gt;getManager();

    $categories = $em-&gt;getRepository('IbwJobeetBundle:Category')-&gt;getWithJobs();

    foreach($categories as $category)
    {
        $category-&gt;setActiveJobs($em-&gt;getRepository('IbwJobeetBundle:Job')-&gt;getActiveJobs($category-&gt;getId(), $this-&gt;container-&gt;getParameter('max_jobs_on_homepage')));
        $category-&gt;setMoreJobs($em-&gt;getRepository('IbwJobeetBundle:Job')-&gt;countActiveJobs($category-&gt;getId()) - $this-&gt;container-&gt;getParameter('max_jobs_on_homepage'));
    }

    return $this-&gt;render('IbwJobeetBundle:Job:index.html.twig', array(
        'categories' =&gt; $categories
    ));
}

The countActiveJobs function has to be added to the JobRepository:

// ...

public function countActiveJobs($category_id = null)
{
    $qb = $this-&gt;createQueryBuilder('j')
        -&gt;select('count(j.id)')
        -&gt;where('j.expires_at &gt; :date')
        -&gt;setParameter('date', date('Y-m-d H:i:s', time()));

    if($category_id)
    {
        $qb-&gt;andWhere('j.category = :category_id')
        -&gt;setParameter('category_id', $category_id);
    }

    $query = $qb-&gt;getQuery();

    return $query-&gt;getSingleScalarResult();
}

// ...

Now you should see the result in your browser:

Day-7 - category link

Category Controller Creation

It’s now time to create the Category controller. Create a new CategoryController.php file in your Controller directory:

namespace IbwJobeetBundleController;

use SymfonyBundleFrameworkBundleControllerController;
use IbwJobeetBundleEntityCategory;

/**
* Category controller
*
*/
class CategoryController extends Controller
{

}

We could use the doctrine:generate:crud command like we did for the job controller, but we won’t need 90% of the generated code, so we can just create a new controller from scratch.

Update the Database

We need to add a slug column for the category table and lifecycle callbacks for setting this column value:

IbwJobeetBundleEntityCategory:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryCategoryRepository
    table: category
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        name:
            type: string
            length: 255
            unique: true
        slug:
            type: string
            length: 255
            unique: true
    oneToMany:
        jobs:
            targetEntity: Job
            mappedBy: category
    manyToMany:
        affiliates:
            targetEntity: Affiliate
            mappedBy: categories
    lifecycleCallbacks:
        prePersist: [ setSlugValue ]
        preUpdate: [ setSlugValue ]

Remove from the Category entity (src/Ibw/JobeetBundle/Entity/Category.php) the getSlug method we created earlier and run the doctrine command to update the Category entity class:

php app/console doctrine:generate:entities

Now you should have the following added to Category.php:

// ...    
    /**
     * @var string
     */
    private $slug;

    /**
     * Set slug
     *
     * @param string $slug
     * @return Category
     */
    public function setSlug($slug)
    {
        $this-&gt;slug = $slug;

        return $this;
    }

    /**
     * Get slug
     *
     * @return string 
     */
    public function getSlug()
    {
        return $this-&gt;slug;
    }

Change the setSlugValue() function:

// ...

class Category
{
    // ...

    public function setSlugValue()
    {
        $this-&gt;slug = Jobeet::slugify($this-&gt;getName());
    }
}

Now we have to drop the database and create it again with the new Category column and load the fixtures:

php app/console doctrine:database:drop --force
php app/console doctrine:database:create 
php app/console doctrine:schema:update --force
php app/console doctrine:fixtures:load

Category Page

We have now everything in place to create the showAction() method. Add the following code to the CategoryController.php file:

// ...

public function showAction($slug)
{
    $em = $this-&gt;getDoctrine()-&gt;getManager();

    $category = $em-&gt;getRepository('IbwJobeetBundle:Category')-&gt;findOneBySlug($slug);

    if (!$category) {
        throw $this-&gt;createNotFoundException('Unable to find Category entity.');
    }

    $category-&gt;setActiveJobs($em-&gt;getRepository('IbwJobeetBundle:Job')-&gt;getActiveJobs($category-&gt;getId()));

    return $this-&gt;render('IbwJobeetBundle:Category:show.html.twig', array(
        'category' =&gt; $category,
    ));
}

// ...

The last step is to create the show.html.twig template:

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

{% block title %}
    Jobs in the {{ category.name }} category
{% endblock %}

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

{% block content %}
    <div class="category">
        <div class="feed">
            <a href="">Feed</a>
        </div>
        <h1>{{ category.name }}</h1>
    </div>

    <table class="jobs">
        {% for entity in category.activejobs %}
            <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                <td class="location">{{ entity.location }}</td>
                <td class="position">
                    <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                        {{ entity.position }}
                    </a>
                </td>
                <td class="company">{{ entity.company }}</td>
            </tr>
        {% endfor %}
    </table>
{% endblock %}

Including Other Twig Templates

Notice that we have copied and pasted the tag that create a list of jobs from the job index.html.twig template. That’s bad. When you need to reuse some portion of a template, you need to create a new twig template with that code and include it where you need. Create the list.html.twig file:

<table class="jobs">
    {% for entity in jobs %}
        <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
            <td class="location">{{ entity.location }}</td>
            <td class="position">
                <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                    {{ entity.position }}
                </a>
            </td>
            <td class="company">{{ entity.company }}</td>
        </tr>
    {% endfor %}
</table>

You can include a template by using the include function. Replace the HTML <table> code from both templates with the mentioned function:

{{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}
{{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}

List Pagination

At the moment of writing this, Symfony2 doesn’t provide any good pagination tools out of the box so to solve this problem we will use the old classic method. First, let’s add a page parameter to the IbwJobeetBundle_category route. The page parameter will have a default value of 1, so it will not be required:

IbwJobeetBundle_category:
    pattern: /category/{slug}/{page}
    defaults: { _controller: IbwJobeetBundle:Category:show, page: 1 }

# ...

Clear the cache after modifying the routing file:

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

The number of jobs on each page will be defined as a custom parameter in the app/config/config.yml file:

# ...

parameters:
    max_jobs_on_homepage: 10
    max_jobs_on_category: 20

Change the JobRepository getActiveJobs method to include an $offset parameter to be used by doctrine when retrieving jobs:

// ...

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

    if($max)
    {
        $qb-&gt;setMaxResults($max);
    }

    if($offset)
    {
        $qb-&gt;setFirstResult($offset);
    }

    if($category_id)
    {
        $qb-&gt;andWhere('j.category = :category_id')
           -&gt;setParameter('category_id', $category_id);
    }

    $query = $qb-&gt;getQuery();

    return $query-&gt;getResult();
}

//

Change the CategoryController showAction to the following:

public function showAction($slug, $page)
{
    $em = $this-&gt;getDoctrine()-&gt;getManager();

    $category = $em-&gt;getRepository('IbwJobeetBundle:Category')-&gt;findOneBySlug($slug);

    if (!$category) {
        throw $this-&gt;createNotFoundException('Unable to find Category entity.');
    }

    $total_jobs = $em-&gt;getRepository('IbwJobeetBundle:Job')-&gt;countActiveJobs($category-&gt;getId());
    $jobs_per_page = $this-&gt;container-&gt;getParameter('max_jobs_on_category');
    $last_page = ceil($total_jobs / $jobs_per_page);
    $previous_page = $page &gt; 1 ? $page - 1 : 1;
    $next_page = $page &lt; $last_page ? $page + 1 : $last_page;
    $category-&gt;setActiveJobs($em-&gt;getRepository('IbwJobeetBundle:Job')-&gt;getActiveJobs($category-&gt;getId(), $jobs_per_page, ($page - 1) * $jobs_per_page));

    return $this-&gt;render('IbwJobeetBundle:Category:show.html.twig', array(
        'category' =&gt; $category,
        'last_page' =&gt; $last_page,
        'previous_page' =&gt; $previous_page,
        'current_page' =&gt; $page,
        'next_page' =&gt; $next_page,
        'total_jobs' =&gt; $total_jobs
    ));
}

Finally, let’s update the template

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

{% block title %}
    Jobs in the {{ category.name }} category
{% endblock %}

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

{% block content %}
    <div class="category">
        <div class="feed">
            <a href="">Feed
            </a>
        </div>
        <h1>{{ category.name }}</h1>
    </div>

    {{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}

    {% if last_page > 1 %}
        <div class="pagination">
            <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': 1 }) }}">
                <img src="{{ asset('bundles/ibwjobeet/images/first.png') }}" alt="First page" title="First page" />
            </a>

            <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': previous_page }) }}">
                <img src="{{ asset('bundles/ibwjobeet/images/previous.png') }}" alt="Previous page" title="Previous page" />
            </a>

            {% for page in 1..last_page %}
                {% if page == current_page %}
                    {{ page }}
                {% else %}
                    <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': page }) }}">{{ page }}</a>
                {% endif %}
            {% endfor %}

            <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': next_page }) }}">
                <img src="{{ asset('bundles/ibwjobeet/images/next.png') }}" alt="Next page" title="Next page" />
            </a>

            <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': last_page }) }}">
                <img src="{{ asset('bundles/ibwjobeet/images/last.png') }}" alt="Last page" title="Last page" />
            </a>
        </div>
    {% endif %}

    <div class="pagination_desc">
        <strong>{{ total_jobs }}</strong> jobs in this category

        {% if last_page > 1 %}
            - page <strong>{{ current_page }}/{{ last_page }}</strong>
        {% endif %}
    </div>
{% endblock %}

The result:

Day 7 - pagination
Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.