Embauchez-moi

Je suis freelance ! Besoin d'un professionnel du développement web ? Pourquoi ne pas me passer un coup de fil ?

Plus d'infos sur… http://thibault.jouannic.fr

mots-cles : Ingénieur web freelance Symfony eZ Publish Solr

Symfony form : pick or create

Here’s an other tutorial about the Symfony form framework. Last time, we managed to make embedded i18n forms optionals. In today’s howto, we will talk about embedded forms again.

In the last tutorials, we created a form to edit news. Today we will create another form to write interviews. An interview is just an article about a celebrity. So our Interview class will inherit from our Article one, so we can reuse our last time work.

Here’s the complete schema we will work with :

Article:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, body ]
      actAs:
        Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
 
  columns:
    title: { type: string(255), notnull: true }
    body: { type: clob, notnull: true }
    author: { type: string(255), notnull: false }
 
News:
  inheritance:
    extends: Article
    type: concrete
 
Celebrity:
  actAs:
    Sluggable:
      fields: [ firstname, lastname ]
  columns:
    firstname: { type: string(255), notnull: true }
    lastname: { type: string(255), notnull: true }
 
Interview:
  inheritance:
    extends: Article
    type: concrete
 
  columns:
    celebrity_id: { type: integer, notnull: true }
 
  relations:
 
    Celebrity:
      local: celebrity_id
      foreign: id
      type: one
      foreignType: one
      foreignAlias: Interviews

And the fixtures :

Interview:
  i1:
    Author: 'Thibault J.'
    Celebrity:
      firstname: Thibault
      lastname: Jouannic
    Translation:
      fr:
        title: Auto-interview
        body: |
          Bla bla bla question et réponses

As usual…

php symfony build --all --and-load
php symfony doctrine:generate-admin backend Interview

Before we start

Before we start, let’s stop and think a few seconds. How do we want to manage our celebrity relation into our form ? Take a look at the default form, without any modification :

01_celebrity
Creative Commons License

Quite bad, isn’t it ? We could make a little better by adding a toString method.

// lib/model/doctrine/Celebrity.class.php
 
class Celebrity extends BaseCelebrity
{
  public function __toString()
  {
    return sprintf('%s %s', $this->getFirstname(), $this->getLastname());
  }
}
02_celebrity
Creative Commons License

That’s a bit better. The problem with this form is that you cannot add a new celebrity. If you want to create a new interview, you’ll have to chose among the existing celebrities. Of course, you could create an other admin module to manage celebrities, but a good programmer is a lazy one. And that would be great if we could create a new celebrity directly in the interview form.

In fact, that’s quite easy, using the embedRelation function.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
    $this->embedRelation('Celebrity');
  }
}
03_celebrity
Creative Commons License

Here’s the result, which is quite relevant to what we wanted. But !? Wait ! There is a regression. What if we want to choose among existing celebrities, the way we used to do ? What we really need is a way to combine both methods actually. We need to be able to choose an existing celebrity OR to create a new one.

Pick or create

We will have to use an intermediate form.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
 
    $celebrityId = $this->getObject()->isNew() ? NULL : $this->getObject()->getCelebrity()->getId();
    $form = new InterviewCelebrityForm(array(), array('celebrity_id' => $celebrityId));
    $this->embedForm('Celebrity', $form);
  }
}
 
// lib/form/doctrine/InterviewCelebrityForm.class.php
class InterviewCelebrityForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['celebrity_id'] = new sfWidgetFormDoctrineChoice(array(
      'model' => 'Celebrity',
      'add_empty' => true
    ));
 
    $this->setDefault('celebrity_id', $this->getOption('celebrity_id'));
 
    $form = new CelebrityForm();
    $this->embedForm('new_celebrity', $form);
 
    $this->widgetSchema->setLabel('celebrity_id', 'Choose one…');
    $this->widgetSchema->setLabel('new_celebrity', 'or create a new.');
  }
}
04_celebrity
Creative Commons License

Try to edit the existing interview, and look at the nice result. However, if you try to save the form, you’ll have a lot of validations errors. There is still some work to do before we can get rid of all those red alerts.

05_celebrity
Creative Commons License

Form validation

The form validation strategy is quite simple. If an existing celebrity is chosen with the drop down list, the firstname and lastname should be ignored. If the list is empty, we must validate the firstname and lastname. At last, we must throw an error if all the fields are empty.

// lib/form/doctrine/InterviewCelebrityForm.class.php
 
    // add this  at the end of the configure() function
    $this->validatorSchema['celebrity_id'] = new sfValidatorPass();
 
    $celebrityValidatorSchema = clone $this->validatorSchema['new_celebrity'];
    $this->validatorSchema['new_celebrity'] = new sfValidatorPass();
 
    $this->validatorSchema->setPostValidator(
      new sfValidatorAnd(array(
        new sfValidatorOr(array(
          new sfValidatorSchemaFilter('celebrity_id', new sfValidatorDoctrineChoice(array(
            'model' => 'Celebrity',
            'required' => true
          ))),
          new sfValidatorSchemaFilter('new_celebrity', $celebrityValidatorSchema)
        )),
        new sfValidatorCallback(array(
          'callback' => array($this, 'checkCelebrity')
        ))
      ))
    );
 
  // add this function into the class
  /**
   * Either who choose an existing celebrity, either we create a new one
   *
   * Unset values according to the choice we made
   **/
  public function checkCelebrity($validator, $values, $argument)
  {
    if(!empty($values['celebrity_id']))
    {
      unset(
        $this['new_celebrity'],
        $values['new_celebrity']
      );
    }
    else
    {
      unset(
        $this['celebrity_id'],
        $values['celebrity_id']
      );
    }
 
    return $values;
  }

Try differents combinations to submit your form. The validation should behave correctly now.

Saving your form

You may have noticed that our interview isn’t updated correctly after we submit the form. We will have to override the doUpdateObject method in order to update the object correctly on form validation.

// lib/form/doctrine/InterviewForm.class.php
  public function doUpdateObject($values)
  {
    if(!empty($values['Celebrity']['celebrity_id']))
    {
      $this->getObject()->setCelebrityId($values['Celebrity']['celebrity_id']);
    }
    else
    {
      $celebrity = new Celebrity();
      $celebrity->fromArray($values['Celebrity']['new_celebrity']);
      $this->getObject()->Celebrity = $celebrity;
    }
 
    unset(
      $values['Celebrity'],
      $this['Celebrity']
    );
 
    parent::doUpdateObject($values);
  }

That’s it ! Everything should be alright now. But just to make sure, here’s a few tests.

Test your forms

We are reusing some code from the last day, so I just will output the new part here.

// test/function/backend/interviewActions.class.php
 
<?php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$interview = Doctrine::getTable('Interview')->
  createQuery('i')->
  leftJoin('i.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('i.id', 'asc')->
  fetchOne();
 
$celebrity = new Celebrity();
$celebrity->setFirstname('toto');
$celebrity->setLastname('tutu');
$celebrity->save();
 
$editUrl = sprintf('/interview/%s/edit', $interview->getId());
 
$browser->
  get('/interview')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - Interview list')->
  with('response')->begin()->
    checkElement('h1', '/Interview List/')->
    checkElement('body', '/Thibault Jouannic/')->
  end()->
 
// …
 
   info('  2.8 - The current celebrity is already selected')->
  get($editUrl)->
  with('response')->begin()->
    checkElement('#interview_Celebrity_celebrity_id option[selected="selected"]', sprintf('/%s/', $interview->getCelebrity()))->
  end()->
 
  info('  2.9 - The current celebrity can be updated')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => $celebrity->getId()
      )
    )), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId()
    ))->
    info('  2.10 - Updating the celebrity does not create empty records')->
    check('Celebrity', array(), 4)->
  end()->
 
  info('  2.11 - A new celebrity can be created')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => '',
      'NewCelebrity' => array(
        'firstname' => 'oncle',
        'lastname' => 'picsou'
      )
    )
  )), array('_with_csrf' => true)
  )->
  with('doctrine')->begin()->
    check('Celebrity', array(), 5)->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId() + 1
    ))->
  end()
;

That’s all folks ! See you soon.

L’automobiliste du vendredi soir

Il m’arrive d’être stupide, parfois. Si si, ça m’arrive. Tenez, aujourd’hui par exemple, j’ai été assez bête pour aller faire des courses en centre ville, un vendredi, à 17h30. Moi qui suis freelance, et travaille à domicile, avouez, faut quand même pas être futé.

Parce qu’à 17h30, à Montpellier, dans le cente-ville, il y a beaucoup de voitures immobiles. À la limite, dans la mesure ou je suis cycliste, ces voitures ne me dérangeraient pas plus que ça, si chacune d’entre elle ne contenait un petit automobiliste aigri et hargneux.

Car l’automobiliste du vendredi soir, exténué par sa semaine de travail, et pressé de rentrer chez lui s’affaler devant sa télé, a tout de la version bipède du bichon. Mais si, ce petit monstre au poil blanc, version animale de la brosse à chiottes, à l’aboiement strident, et dont il est impossible de distinguer l’avant du derrière.

C’est amusant l’évolution. Prenons le cas des chats, par exemple. À l’instar d’un pigeon, un chat peut objectivement être considéré comme un animal à faible valeur ajoutée. En gros, un chat, ça bouffe, ça dort, ça casse deux ou trois trucs de temps en temps, mais à part ça, ça ne sert à rien

Seulement voilà, l’évolution a conféré au chat, notamment dans sa version juvénile, un minois si adorable qu’il est tout bonnement impossible pour un être humain normalement constitué de ne pas s’y attacher. Et c’est ainsi que l’espèce du chat, siècle après siècle, continue de se perpétuer, comptant sur sa miraculeuses faculté à se faire offrir à bouffer.

Quand aux bichons, honnêtement, je ne comprends pas comment ils ont réussi à ne pas tous se faire transformer en saucisses. Allons, je ne désespère pas.

pequeñita
Creative Commons License photo credit : Difusa

Mais je digresse, je divague, et je m’éloigne de mon sujet[1]. Sujet qui est, je le rappelle pour les moins attentifs d’entres vous, l’aggressivité hargneuses de l’automobiliste du vendredi soir. Car si le bichon aboie, l’automobiliste lui klaxonne rageusement, convaincu que grâce à un mystérieux phénomène physique, plus il fera de bruit, plus vite il sera chez lui.

Les geeks le savent, le vendredi est le jour du troll. Le jour ou il est toléré, dans les limites imposées par le bon sens, de prendre quelques libertés avec la netiquette. Sans doute inspirés par cette charmante coutume, les automobilistes, eux aussi, décident parfois de prendre quelques libertés avec la «  netiquette de la route  ».

Seulement voilà, contrairement à la netiquette du web, qu’il est recommandé de suivre pour le confort de tous, la nétiquette de l’automobiliste, plus connue sous le nom de «  code de la route  », n’est pas facultative. En fait, le code de la route est même un texte législatif, un truc quand même un peu sérieux, du genre qu’on n’est pas censé trop faire le guignol avec.

Alors, puisque j’ai la chance de disposer ici d’un espace d’expression libre, et que peut-être, il reste un ou deux automobilistes à lire ces lignes, voici quelques règles que je souhaite rappeler à leur attention :

  1. En agglomération, l’usage de l’avertisseur sonore n’est autorisé qu’en cas de danger immédiat. (art. R416-1 du code de la route). Je tiens à préciser que l’hypothétique possibilité de pouvoir espérer faire avancer sa voiture de quelques centimètres ne constitue pas un danger immédiat  ;
  2. L’arrêt ou le stationnement d’un véhicule empiétant sur un passage prévu à l’intention des piétons est interdit. (art. Article R417-5 du code de la route)  ;

Bref ! Tout ce que je viens de tartiner pourrait en fait être résumé de manière assez concise : Si vous êtes un automobiliste, que vous klaxonnez en agglomération juste parce que vous êtes fatigué, et/ou stationnez sur les passages piétons (même quelques secondes), ne vous cherchez pas d’excuses : vous êtes un con et vous me faites chier.

C’était vendredi, merci de votre attention, et bon week-end à vous.

Notes :

  1. Mais bon, pour une fois que je peux illustrer mon blog avec des photos de chats. [retour]

Optional translation form for I18n objects with Symfony and Doctrine

Scaffolding: Not just for construction workers anymore
Creative Commons License photo credit : kevindooley

There are people around here claiming that the Symfony form framework is a gift from the gods. One might say that this statement is a bit exaggerated, but it’s true that the release of the 1.3/1.4 version of the Symfony brought many improvements, and yes, the form framework can save you a lot of time.

In one of my current project, I had to developp some basic CMS-like features. In a back-office, an admin should be able to writes different kind of articles (news, interviews…). Hey ! That’s a job for inheritance ! But wait, there’s more. Articles can be translated in several languages. Easy, with the Doctrine I18n behavior.

One last requirement : article translations are optional. One might publish an article in english, an other in french, and a third in both languages. This is a little more tricky, so let’s see how to do this.

Some references

Before we start, here are the wonderful ressources I read to get my way out :

http://prendreuncafe.com/blog/post/2009/11/29/Embedding-Relations-in-Forms-with-Symfony-1.3-and-Doctrine
http://symfonyguide.wordpress.com/2009/09/28/symfony-forms-saving-process/
http://vousavezchoisi.com/img/formsdiagram.png
http://www.symfony-project.org/more-with-symfony/1_4/en/06-Advanced-Forms#chapter_06_ignoring_embedded_forms
http://www.ze-technology.com/2009/06/05/tests-fonctionnels-avec-symfony-gerer-lidentification/

Let’s start with the schema

Article:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, body ]
      actAs:
        Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
 
  columns:
    title: { type: string(255), notnull: true }
    body: { type: clob, notnull: true }
    author: { type: string(255), notnull: false }
 
News:
  inheritance:
    extends: Article
    type: concrete

As it’s friday, I will also give you some fixtures :

News:
  n1:
    author: 'Fantomas'
    Translation:
      fr:
        title: 'OK, l''Ipad est sorti. Vous allez me foutre la paix maintentant ?'
        body: |
          Vous commencez serieusement à me gonfler avec vos articles et vos tweets sur
          une techno inutile et bardée de DRM.
 
  n2:
    author: 'Garcin Fony'
    Translation:
      fr:
        title: 'Sinon, à part ça, ça va ?'
        body: |
          Allez, pour me calmer, je vais me refaire un p'tit café

Let’s build the whole thing, and generate the admin in the same time.

php symfony doctrine:build --all --and-load
php symfony generate:app backend
php symfony doctrine:generate-admin backend News

Check the module admin we’ve just build. Click on the first «  edit  » button. Wait a minute ? The I18n fields are just missing, how are we gonna edit our articles ? If you’re not familiar with the I18n behavior, you must know that the content is split into two tables. The new table, «  article_translation  », holds all the I18n fields.

mysql> SELECT * FROM news;
+----+-------------+---------------------+---------------------+
| id | author      | created_at          | updated_at          |
+----+-------------+---------------------+---------------------+
|  1 | Fantomas    | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
|  2 | Garcin Fony | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
+----+-------------+---------------------+---------------------+
 
mysql> SELECT id, lang, title FROM news_translation;
+----+------+------------------------------------------------------------------+
| id | lang | title                                                            |
+----+------+------------------------------------------------------------------+
|  1 | fr   | OK, l'Ipad est sorti. Vous allez me foutre la paix maintentant ? | 
|  2 | fr   | Sinon, à part ça, ça va ?                                     | 
+----+------+------------------------------------------------------------------+

So, to be able to edit our translations, you have to embed the I18n forms, using the embedI18 function. Let’s do that.

// lib/form/doctrine/NewsForm.class.php
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
    $this->embedI18n(array('fr', 'en'));
  }
}

Reload the edit page. Now you can edit your news in french and english. Pretty cool, isn’t it ?

Let’s refactor this mess

Since now, it’s been pretty easy. However, the code we’ve written is bad. It’s bad, for the following reasons :

  1. Each time we will add a new article type (e.g. interview, etc.), we will have to update it’s configure method  ;
  2. Each time we will change the available languages (to add a new one, or disable an existing one), we will have to edit every forms  ;

Fortunately, since Symfony 1.3, the form inheritance follow the model structure. Notice that NewsForm extends BaseNewsForm, which extends ArticleForm.

Let’s refactor our code :

# config/app.yml
all:
  cultures:
    enabled:
      fr: Français
      en: English
// lib/form/doctrine/NewsForm.class.php
 
// Revert the changes we added there
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
  }
}
 
// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
}

Reload the edit form. Now, no matter how many articles sub-types you add, you still can configure available languages from one place.

Translation edition

Let’s try our brand new admin module by editing a news. If you used the fixtures I provided, you should have a french article, with no english translation. Try to update the french version, and click on the ‘Save’ button.

This is a miserable failure. We can’t save the form, because the english fields are required. We’re stuck, we have to fill every translation for an article before we can save it. Wouldn’t it be nice if we could make a translation form optional ? Let’s add a requirement : if every fields in a translation form are empty, it just should be ignored.

To achieve this goal, we will update the doBind method. Here’s the new ArticleForm class.

// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
 
  /**
   * Cleans and binds values to the current form
   *
   * Ignore i18n forms when all their fields are empty
   *
   * @see sfForm::doBind
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormIsEmpty($values[$lang]))
      {
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  /**
   * Check if every fields, except for id and lang, are empty
   **/
  protected function embeddedI18nFormIsEmpty(array $values)
  {
    foreach($values as $key => $value)
    {
      if(in_array($key, array('id', 'lang')))
        continue;
 
      if('' !== trim($value))
      {
        return false;
      }
    }
    return true;
  }
}

In the overidden doBind method, we check, for every I18n form, if some values has been submited. If not, we just unset the corresponding fields.

Reload the edit form, update the french translation, leave the english field empty, and click on ‘Save’. Hurray, the forms is saved correctly.

However, you should keep reading, because you’re gonna have a surprise. Let’s look into the database.

mysql> SELECT id, lang, slug FROM news_translation;
+----+------+--------------------------------------------------------------+
| id | lang | slug                                                         |
+----+------+--------------------------------------------------------------+
|  1 | en   |                                                              | 
|  1 | fr   | ok-l-ipad-est-sorti-vous-allez-me-foutre-la-paix-maintentant | 
|  2 | fr   | sinon-a-part-ca-ca-va                                        | 
+----+------+--------------------------------------------------------------

Something went wrong ! Somewhere in the process, Symfony created an empty translation object for our article. Notice the void «  slug  » field ? If you try to edit another news, you will receive a duplicate content error. WTF ?

I must admit that I don’t fully understand why this happens (sometimes I think I do, and a few minutes later, I realize I don’t). However, here’s the solution to overcome this problem.

// lib/form/doctrine/ArticleForm.class.php
 
  // Add this at the beginnig of the class:
  /**
   * I18n ignored forms
   **/
  protected $I18nFormsIgnored = array();
 
  // update the doBind method:
  /**
   * Unset i18n forms values when every field is empty
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormEmpty($values[$lang]))
      {
        $this->I18nFormsIgnored[] = $lang;
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  // And override the doUpdateObject method:
  /**
   * Updates the values of the object with the cleaned up values.
   *
   * @param  array $values An array of values
   *
   * @see sfFormDoctrine::doUpdateObject()
   */
  protected function doUpdateObject($values)
  {
    parent::doUpdateObject($values);
 
    foreach($this->I18nFormsIgnored as $lang)
    {
      unset($this->object->Translation[$lang]);
      unset($values[$lang]);
    }
  }

Delete the useless line in the mysql database, so we can start on a clean base. Once more, reload the edit form, leave the english translation empty, save the form, et voilà ! The form is saved, the translation is updated, and no empty line is added in the table.

Adding and deleting a translation

Just to be sure, add some data in the english translation title, and try to save the form. You should get a «  body is required  » error, which is the expected behavior. Try to fill correctly the english translation, and save again. Check that the english translation is correctly added in the database. Erase all french fields, save one more time, and check that the french translation is definitly removed from the DB (no dummy entry).

Well, it seems that our form is pretty functional. One last thing : there is still a «  slug  » field in each translation form, that you might want to unset. Easy, just edit the ArticleTranslationForm.class.php file :

// lib/form/doctrine/ArticleTranslationForm.class.php
 
class ArticleTranslationForm extends BaseArticleTranslationForm
{
  public function configure()
  {
    unset($this['slug']);
  }
}

Edit a news. If you’re using Symfony 1.{3,4}.1, you’ll see that the damn slug field is still there. It’s a Symfony bug. Indeed, the translations forms don’t follow the inheritance schema. Notice that BaseNewsTranslationForm extends BaseFormDoctrine instead of ArticleNewsTranslation.

You’ll have to manually change that, however, this will be overidden each time you rebuild your forms.

// lib/form/doctrine/base/BaseNewsTranslationForm.class.php
// Replace
abstract class BaseNewsTranslationForm extends BaseFormDoctrine
 
// with
abstract class BaseNewsTranslationForm extends ArticleTranslationForm
 
// lib/form/doctrine/NewsTranslationForm.class.php
// Edit the configure method
class NewsTranslationForm extends BaseNewsTranslationForm
{
  public function configure()
  {
    parent::configure();
  }
}

Now, the slug field should disapear. At the present moment, the bug is still open.

Let’s test everything

As it’s friday, I feel generous, so as a bonus, I will give you the functional tests to cover our magical form. Enjoy.

// lib/sfBackendTestFunctional.class.php
/**
 * This class is used to run functional tests in a secured backend
 *
 * Takes care of the login action, and the fixtures loading
 */
class sfBackendTestFunctional extends sfTestFunctional
{
  public function __construct($browser, $lime = null, $testers = array())
  {
    parent::__construct($browser, $lime, $testers);
  }
 
  /**
   * Perform user authentication
   *
   * @param   array of String         $user_data
   * @return  sfGuardTestFunctional   $this
   */
  public function signin($user_data)
  {
   return $this->info(sprintf('Login as "%s"', $user_data['username']))->
     get('/login')->
     click('sign in', array('signin' => $user_data), array('_with_csrf' => true))->
 
     with('form')->begin()->
       hasErrors(false)->
     end()->
 
     with('user')->begin()->
       isAuthenticated(true)->
     end()->
 
     with('request')->begin()->
       isParameter('module', 'sfGuardAuth')->
       isParameter('action', 'signin')->
     end()->
 
     with('response')->begin()->
       isRedirected()->
       followRedirect()->
       end()
    ;
  }
  /**
   * Load project fixtures
   **/
  public function loadData()
  {
    Doctrine::loadData(sfConfig::get('sf_data_dir').'/fixtures');
    return $this;
  }
}
// test/functional/backend/newsActionsTest.php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$languages = sfConfig::get('app_cultures_enabled');
 
$news = Doctrine::getTable('News')->
  createQuery('n')->
  leftJoin('n.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('n.id', 'asc')->
  fetchOne();
 
$editUrl = sprintf('/news/%s/edit', $news->getId());
 
$browser->
  // Uncomment this if your backend is protected by a login
  //signin(array(
  //  'username' => 'admin',
  //  'password' => 'admin'
  //)))->
  get('/news')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - News list')->
  with('response')->begin()->
    checkElement('h1', '/News List/')->
    checkElement('body', '/Fantomas/')->
  end()->
 
  info('2 - News edit')->
  info('  2.1 - I18n forms for all languages are embedded')->
  get($editUrl)->
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('input[name$="[title]"]', count($languages))->
  end()->
 
  info('  2.2 - I18n forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(1)->
    isError('en[body]', 'required')->
  end()->
 
  info('  2.3 - Empty forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  info('  2.4 - Translations are updated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
      'title' => 'toto tutu tata'
    ))->
    info('  2.5 - No empty translation is created')->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
    ), false)->
  end()->
 
  info('  2.6 - New translations can be added')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
 
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
      'title' => 'toto tutu tata'
    ))->
  end()->
 
  info('  2.7 - Existing translations can be deleted')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
    ), false)->
  end()
;

Here we are. Hope you find this helpful. I thought some kind of requirement would be pretty common, however, I didn’t find any directly related howto’s. If you can think of any other way to achieve this, please, let me know.

Créer une liste triable avec Symfony et jquery ui

Symfony backlog
Creative Commons License

Lors de mes développements de ces derniers jours, je suis tombé sur un besoin qui me semblait relativement simple. Je voulais être capable de trier rapidement une liste d’éléments. Et comme, malgré tout, j’ai un peu galéré à trouver des tutoriaux à jour, je vous livre ici le fruit de mes travaux.

J’utiliserai pour ce faire la librairie jquery UI, elle même basée sur Jquery.

Attention, hein, c’est samedi matin, je suis mal réveillé, − edit : tellement mal réveillé que j’ai publié l’article avant de l’avoir terminé. fail… − alors je raccourcirai au maximum. Si je ne suis pas clair, laissez moi des commentaires.

Comme d’hab, commençons par le modèle

Je voulais trouver un outil capable de gérer un backlog de produit à la scrum, histoire de communiquer avec mes clients à distance. Cependant, malgré l’existence d’excellents outils dédiés à cette sympathique méthodologie agile, je n’ai pas réussi à trouver quelque chose de vraiment simple et répondant à mes besoins. Alors, en bon geek, j’ai choisi de coder le mien.

Project:
  actAs:
    Sluggable:
      fields: [ title ]
      unique: true
 
  columns:
    id: { type: integer, primary: true, autoincrement: true }
    title: { type: string(255), notnull: true }
 
Story:
  actAs:
    Timestampable: ~
 
  columns:
    id: { type: integer, primary: true, autoincrement: true }
    project_id: { type: integer, notnull: true }
    description: { type: clob, notnull: true }
    priority: { type: integer(2), default: 0 }
    effort: { type: integer(2), default: 0 }
 
  relations:
    Project:
      foreignAlias: Stories
      local: project_id
      foreign: id
      type: one
      foreignType: many

Pour les paresseux, je vous colle aussi les fixtures :

 
Project:
  demo:
    title: 'Demo project'
 
Story:
  s1:
    description: 'En tant qu''anonyme, je peux m''inscrire sur le site pour devenir membre'
    priority: 1
    effort: 5
    Project: demo
 
  s2:
    description: 'En tant que membre, je dispose d''une page d''accueil pour éditer mon profil et voir l''activité de mon réseau'
    priority: 3
    effort: 13
    Project: demo
 
  s3:
    description: 'En tant que rédacteur, je peux écrire des articles de type magazine et les publier dans des catégories'
    priority: 2
    effort: 25
    Project: demo

Voilà pour le modèle. Je vous laisse construire tout ça, vous connaissez le topo. Créez ensuite un module «  project  », avec une action «  show  » qui affichera une liste de users stories. Je vous passe le code de l’action qui n’a rien de spécial. Dans le template «  showSuccess.php  » du module project, ajoutez :

// bla bla bla
<div id="stories">
<?php include_partial('story/list', array('stories' => $project->getStories())) ?>
</div>

Vous aurez bien entendu surchargé la fonction «  getStories  » pour trier les histoires par priorité.

Créez ensuite un module «  story  », et ajoutez-y un template «  _list.php  » …

<table class="stories">
<tbody>
<?php foreach($stories as $story): ?>
    <tr class="story" id="story_<?php echo $story->getId() ?>">
      <?php include_partial('story/detail', array('story' => $story)) ?>
    </tr>
<?php endforeach ?>
</tbody>
<thead>
  <tr>
    <th>#id</th>
    <th>Description</th>
    <th>Effort</th>
  </tr>
</thead>
</table>

Ainsi qu’un autre template «  _detail.php  »

<td>
  <a href="#" class="sort-button fg-button fg-button-icon-left ui-state-default ui-corner-all">
    <span class="ui-icon ui-icon-arrowthick-2-n-s"></span>
    <?php echo $story->getId() ?>
  <a>
</td>
<td><?php echo $story->getDescription() ?></td>
<td><?php echo $story->getEffort() ?></td>

Vous voilà donc avec une belle liste de users stories, affichées par ordre de priorité, et que vous souhaiteriez pouvoir réordonner par drag’n'drop.

Des p’tits tris, des p’tits tris, encore des p’tits tris…

Dans le répertoire web, ajoutez dans votre fichier js maison (créez le s’il n’existe pas) le code suivant.

$(document).ready(function()
{
  $("#stories table tbody").sortable({
    // limitons les déplacements sur l'axe des ordonnées, ce sera plus propre
    axis: 'y',
 
    // Il faut cliquer sur cet élément pour pouvoir initier le drag'n'drop
    handle: '.sort-button',
 
    // Créons un joli trou stylé lors des déplacements
    placeholder: 'ui-state-highlight',
    forcePlaceholderSize: true,
 
    // Cette fonction permet à notre ligne de conserver son formatage lors du déplacement
    // Pas vraiment utile, mais plus agréable à l'œil
    helper: function(e, tr)
    {
      var $originals = tr.children();
      var $helper = tr.clone();
      $helper.children().each(function(index)
      {
        // Set helper cell sizes to match the original sizes
        $(this).width($originals.eq(index).width())
      });
      return $helper;
    },
 
    // La fonction appelée quand un élément change de position
    // C'est le code vraiment utile, en fait
    update: function(event, ui){
      // Construit un tableau des ids des stories
      serial = $(this).sortable('serialize');
 
      // Appelle une action en ajax
      $.ajax({
        url: updateorderurl, // set in layout.php
        type: "post",
        data: serial,
        error: function(){
          alert("Error ! Order not updated");
        }
      })
    }
  });
});

Remarquez que la variable «  updateorderurl  » contient l’url de l’action qui va réaliser la réaffectation des priorités. Comme cette url est générée par Symfony, elle est définie dans le contrôleur, puis affectée à une variable javascript dans la layout grâce à un slot. Ça vaut ce que ça vaut.

Normalement, vous devriez maintenant être capable de changer l’ordre des stories côté frontend. Bien entendu, le code métier chargé de gérer le réordonnancement n’existe pas encore.

Au cœur du métier

Créons donc une nouvelle action dans le module «  story  ».

  public function executeUpdateOrder(sfWebRequest $request)
  {
    // Il nous faut un moyen de récupérer le projet en question
    $project = Doctrine::getTable('project')->find($request->getParameter('project_id'));
    $this->forward404Unless($project);
 
    // Correspond à la variable 'serial' dans le js, vous vous souvenez ?
    // C'est un simple tableau d'ids
    $order = $request->getParameter('story');
    $project->updateStoriesOrder($order);
 
    return sfView::HEADER_ONLY;
  }

Nous revoilà repartis dans le modèle. Éditons notre classe Project.

// lib/model/doctrine/Project.class.php
// …
  /**
   * Update the stories order
   *
   * @param array $order An array with the stories ids, sorted by priority
   **/
  public function updateStoriesOrder(array $order)
  {
    foreach($order as $priority => $storyId)
    {
      $story = Doctrine::getTable('story')
        ->find($storyId);
 
      if(!$story || $story->getProjectId() != $this->getId())
        throw new Exception('moo');
 
      $story->setPriority($priority);
      $story->save();
    }
  }

Tadaaaaaam ! Ça devrait fonctionner. Voilà, c’est tout. Tiens, au moment où je finis d’écrire ces lignes, je m’aperçois qu’un plugin censé faire exactement la même chose vient de sortir. Frustration. Bon, tant pis, bon week-end quand même.

Colombo 2.0

Dés qu’elle est entrée dans mon bureau, j’ai su que l’affaire ne sentait pas bon. On ne fait pas carrière dans ma branche si on ne développe pas un solide sixième sens pour flairer les problèmes. Et manifestement, mon embrouillomètre me criait que je ferais mieux de me planquer sous mon bureau la tête cachée dans une des poches de mon imper.

Malheureusement, mon vieux fauteuil donnait des signes de fatigue, la machine à café agonisait, et j’avais un besoin urgent de renflouer mes caisses. Je n’avais pas les moyens de faire le difficile.

— Thibault Jouannic, détective TMA privé ?
— Lui même, à votre service m’dame. Entez, je vous en prie.

D’un signe de main, je l’invitai à s’asseoir. Pendant qu’elle s’exécutait, je l’examinais à la dérobée. Mon boulot m’amenait plutôt à côtoyer de gros barbus aux dents jaunies par la clope et le café, et portants des tee-shirt rigolos. Aussi, cette belle brune élancée en tailleur élégant avait immédiatement éveillé mon attention. D’un point de vue professionnel, j’entends.

— Alors m’dame, que puis-je faire pour vous être utile ?

J’ai toujours pensé qu’un bon détective était comme un médecin. Pas de bon diagnostic sans connaître les symptômes. Le problème, c’est que mes clients me prenaient souvent pour une espèce de devin vaudou, et j’étais obligé de les soumettre à un interrogatoire digne d’un inquisiteur espagnol pour espérer obtenir le minimum d’indices.

— Je suis Lucie S., webmaster d’un site de petites annonces agricoles, et depuis quelques temps il semble que les performances ne soient plus au rendez-vous.
— Vous voulez dire que le site est lent ?
— En fait, ce site n’a jamais été une foudre de guerre, mais depuis deux jours, la situation est devenue catastrophique. La moindre requete nécessite 45 secondes avant d’aboutir. Quand on ne récupère pas une page blanche. Et le moteur de recherche, le cœur du site, est complètement inutilisable.

Dr Cog
Creative Commons License photo credit : Balakov

Les performances ! Un grand classique. 90% de mes affaires concernaient des sites qui se trainaient comme des escargots neurasthéniques. Parfois, rajouter quelques caches rendait le problème supportable, mais la plupart du temps, la conception de base était tellement médiocre qu’il n’y avait rien à faire. Allez expliquer ça à un client désespéré.

Bref ! Cela s’annonçait comme une petite enquête de routine. Alors pourquoi mon instinct me criait-il qu’il y avait anguille sous roche ?

— Vous dites que depuis deux jours, la situation a empiré. Que s’est-il passé de spécial à ce moment là ?
— Mais justement ! Rien, rien du tout.

C’était louche. Soit elle me mentait, soit elle ignorait quelque chose, mais son histoire n’était pas crédible, et elle même ne semblait pas convaincue. Je décidai de la titiller.

— Aucune erreur dans vos tests unitaires ou fonctionnels ? Que dit votre plate-forme d’intégration continue ?
— Euh…

J’avais touché juste. Je posais la question pour la forme, j’aurais été bien surpris d’apprendre l’existence de tests automatisés. La plupart du temps, je devais m’estimer heureux si des conventions de codages étaient définies et respectées. Mais bon, la question avait le don de rendre mes clients mal à l’aise, et un client qui n’est pas droit dans ses chausettes est moins enclin à discuter mes tarifs. Et puis, ça me distrait.

— Écoutez, je vais voir ce que je peux faire. Mais il me faudra un accès au serveur de production, un compte sur le gestionnaire de source, et un dump récent de la base de données.
— Je vous communiquerai ces informations dans la journée. Il y a autre chose, me dit-elle l’air embarassée.
— Oui ?

Je masquai un petit sourire. Quand un client ne rechigne pas à vous donner l’accès à la prod, c’est qu’il y a toujours autre chose.

— Un grand salon d’agriculture a lieu dans deux jours. Une présentation publique du site est prévue. C’est un site subventionné par le ministère, vous comprenez. Et si la situation n’est pas revenue à la normale d’ici là, nous risquons de nous retrouver dans une situation délicate.

Ben voyons ! J’aimais travailler dans le stress du temps qui passe. Corriger un bug qui semblait insolvable est gratifiant, mais aucune sensation n’est plus exaltante que celle de le faire dans un délai aussi court. Et puis, ça me permet de gonfler mon tarif.

— Je prends l’affaire en main m’dame. Envoyez moi les données dont j’ai besoin, et je commence sur le champ.

Une fois qu’elle fut sortie, je m’abandonnais à une méditation contemplative. Réfléchir représente une part importante de mon temps de travail. Je dirais même que c’est la part la plus importante. Il m’est même arrivé de résoudre des affaires sans poser les mains sur mon clavier bépo.

L’ennui, avec la réflexion, c’est qu’elle passe facilement pour de l’oisiveté pour les non-inités. C’est pour ça que je prends toujours l’air affairé et soucieux quand j’attends un client. Pour avoir l’air compétent et efficace.

C’est important, les apparences, dans mon métier. Pourquoi croyez-vous que je laisse traîner toutes ces piles de dossiers poussiéreux ? Pas pour le travail, tout est numérisé. Non, c’est simplement parce qu’un détective sans imper et qui ne pue pas le tabac froid. Ça ne fait pas sérieux.

Je suis bien resté deux heures, les pieds sur mon bureau couvert de paperasse bidon, sirotant mon infusion à la verveine (que je ne bois qu’en cachette, un vrai détective est censé carburer au café noir dégueulasse), avant de parvenir à la conclusion suivante : j’étais dans une impasse. J’avais le crime, mais pas le mobile.

Quelqu’un aurait-il saboté volontairement le site pour plomber la présentation publique ? Un hacker ? Un script-kiddie ? Un concurrent ? Je n’arrivais pas à m’en persuader. Les gens regardent trop la télé. Ils s’imaginent que nous autres, détectives TMA, passont notre temps à combattre le crime virtuel organisé, sauver des demoiselles en détresse et empêcher in-extremis des fusées d’exploser. Tout ça, c’est la faute à Colombo.

La réalité est toute autre, et le quotidien d’un détective TMA est finalement assez banal. Point de grand bantitisme. Point de course poursuite effrénée dans les limbes du web. Simplement quelques corrections orthographiques par ci, un petit bug css (damn IE!) par là. Et puis, métro, popote, dodo. Comme tout le monde, finalement.

C’est justement pour cette raison que je ne parvenais pas à accepter l’idée d’un acte de piraterie. Règle n°1 du détective TMA : «  N’attribue jamais à la malveillance ce qui peut s’expliquer par l’incompétence  ». Quelqu’un avait merdé dans cette histoire, et il fallait trouver qui.

Mala Strana
Creative Commons License photo credit : Pensiero

C’est maintenant que les choses sérieuses allaient commencer. Je commençai par vérifier la justesse des informations envoyées par ma cliente, et récupérais le trunk du site. Haha ! Quelqu’un avait commité un «  Thumbs.db  » dans le dossier «  img  ». Quelle bande de newbies ! Mais !? Ils avaient également commité leur répertoire de backup, rempli de dumps sql ?! Seigneur ! Je sentis mes yeux tenter de jaillir hors de leurs orbites.

Pris d’un doute, je récupérai également en local la version de production du site. Autant vous dire qu’à ce moment, je m’attendais à tout. Pour le plaisir, j’ouvris quelques fichiers au hasard grâce à mon éditeur de geek préféré (vim, car tout le monde sait que les vrais pros n’utilisent jamais emacs). La syntaxe hasardeuse, l’indentation erratique, et les caractères de saut de lignes aléatoires me firent vite tourner la tête, le cœur au bord des lèvres.

— «  Fiston, dans la vie, on ne mélange jamais les espaces et les tabulations  », me disait souvent ma grand-mère, de son air sage et pénétrant de vétéran qui avait connu le Cobol. Il faut croire que tout le monde n’a pas eu la chance d’avoir une grand mère comme la mienne.

En dernier recours, je décidai de jeter un coup d’œil à l’historique svn. En parcourant rapidement les derniers messages de commits, quelque chose attira mon regard. Serait-il possible que… Quelques diffs plus tard, j’étais fixé. Les mains derrière la tête, je me laissai aller en arrière, un sourire sur les lèvres. Voilà une affaire rondement menée.

Le cœur léger, je décrochai le téléphone, et appelai ma cliente.

— Allo ?
— Thibault Jouannic, détective TMA à l’appareil. J’ai quelque chose qui peut vous intéresser.
— Vous avez trouvé ?
— Pas par téléphone. Écoutez, je veux que vous réunissiez l’équipe de développement au complet, dans vos locaux, demain 9h. Assurez-vous que tout le monde soit présent. Et amenez des croissants.

Van Williams & Bruce Lee
Creative Commons License photo credit : Elmo Alves

Sans attendre sa réponse, je raccrochai. Pour ma peine, je décidai de me préparer une autre infusion. Demain promettait d’être une belle journée.

Le lendemain, je me rendis au siège de S… S.A., dans ma vielle guibarde fumante. J’ai l’habitude de me déplacer en vélo, mais pour les rencontres avec mes clients, ça me donne un genre.

Quand j’arrivai à la réunion, tout le monde était déjà présent, et à l’heure, preuve s’il en est de la gravité de la situation. Les croissants étaient chauds, le café finissait de glouglouter dans le percolateur, les choses s’annonçaient bien.

Je m’emparai d’une viennoiserie, et demandai à chacun de se présenter.

— Frédéric B., chef de projet
— Solange C., développeuse
— Alfred D., designer / intégrateur
— Anatole P., développeur stagiaire
— Pierre Z, développeur
— Lucie S., webmaster

Après ce petit tour de table, je leur tournai le dos, et m’approchai de la fenêtre, faignant de contempler le paysage bétonneux des alentours d’un air inspiré. Dans mon métier, il faut savoir ménager ses effets. Lorsque je jugeai la tension nerveuse suffisante, je me retournai, et commençai mon numéro.

— Comme vous le savez, j’ai été contacté hier par Lucie S., ici présente, pour élucider un cas mystérieux. Depuis 2 jours, le site www.….com semble rencontrer des problèmes de lenteur anormale. Est-ce exact ?
— Tout à fait.
— Cet évenement survient juste avant une importante présentation publique du site. Si l’anomalie n’est pas résolue, sa réputation sera ruinée, et par exension celle de l’agence web qui l’a conçu et réalisé, n’est-ce-pas ?
— En effet.
— Comme vous, j’ai pensé à un acte malveillant. La vile démarche d’un concurrent amer et jaloux d’avoir été éconduit lors de l’appel d’offre. Aussi ai-je analysé en détails les différents logs de votre serveur de production. J’ai vite découvert que je faisais fausse route. Je n’ai rien remarqué d’anormal. En revanche, je peux vous dire une chose.

Je les parcourus tous de mon regard de détective pénétrant. La tension était à son comble, chacun était pendu à mes lèvres, retenant son souffle.

— Mesdames, messieurs, il s’agit d’un acte interne. Qui plus est, j’affirme que le coupable se trouve dans cette pièce !

L’annonce fit son effet. Des exclamations d’indignation éclatèrent, des cafés furent renversés, certains manquèrent s’étouffer avec leur croissant. J’attendis, inflexible, que le calme revienne, et imposai le silence de mon œil de lynx (la bestiole, pas le navigateur).

— Lucie S., vous m’avez annoncé lors de notre premier entretien qu’aucun changement n’étais survenu sur l’application au moment du début des anomalies. Et bien, madame S., vous vous êtes trompée. Il y a bel et bien eu une livraison ce jour là.

Le chef de projet bondit alors de sa chaise, en renversant son café, hurlant presque.

— Mensonge ! Calomnies ! Aucune livraison n’a été déployée en production depuis 10 jours. Vérifiez le svn, le dernier tag date de plus d’une semaine. Et aucun ticket n’est passé en état «  cloturé  » durant cette période ! Vous mentez !
— Croyez-moi bien, j’ai vérifié ces éléments. Et comme vous le dite, aucune livraison n’a été déployée en utilisant votre procédure de livraison officielle. — J’appuyais sur le terme — En revanche, il y a bien eu une livraison sans passer par votre procédure de déploiement !
— C’en est trop, rugit le chef de projet. Comment pouvez-vous l’affirmer ?
— C’est simple, j’ai eu l’occasion de comparer la version svn des sources, avec ce qu’on peut trouver sur vos serveurs de production et préproduction. Le résultat fut trés instructif, je dois dire.

Pens in light
Creative Commons License photo credit : maistora

Je les observai d’un air amusé, avant de reprendre.

— J’ai en effet pu constater que ces trois environnements n’étaient pas synchronisés. Il ne peut y avoir qu’une seule explication. Quelqu’un n’a pas respecté votre procédure de déploiement.

L’assistance resta muette. Des murmures incrédules se firent entendre. Le chef de projet, hagard, bafouilla.

— Mais… Qui ? Qui pourrait faire une chose pareille ?
— Voyez-vous, j’ai eu l’occasion d’analyser les logs svn de votre application. Quelque chose a attiré mon attention. Quelque chose qui n’aurait pas du se trouver là.

Suspense. J’observai une pause théâtrale, avant de m’écrier :

— Votre log svn est en effet truffé de messages de commits vides !

Stupeur dans l’auditoire. Le chef de projet, le souffle coupé, retomba sur sa chaise, effondré. Je m’emparai d’un croissant, leur laissant le temps de reprendre leurs esprits avant de continuer.

— Plus intéressant. J’ai également remarqué que ces commits félons se sont multipliés depuis quelque temps.

Je pointai alors un doigt vengeur vers l’un de mes auditeurs.

— Monsieur Anatole P. Pourriez vous nous indiquer à quel moment vous avez commencé à travailler sur ce projet ?!

Le pauvre stagiaire, au comble de l’embarras, parvint à peine à bredouiller une réponse intelligible.

— Une… une semaine environ. Mon stage a débuté au début du mois.
— Monsieur Anatole P. N’est-il-pas vrai que vous avez, au cours de cette semaine, réalisé des développements sur le site dont nous parlons ?
— Oui, mais…
— Et n’est-il-pas vrai qu’au cours de ces travaux, vous avez modifié l’un des fichiers de configuration de l’application ?
— Peut-être bien que oui.
— Et bien monsieur Anatole P., lorsque vous avez livré vos travaux directement sur le serveur de production, vous avez écrasé un fichier de configuration avec les paramètres de la plate-forme d’intégration. Cela a eu pour action de désactiver tous les caches de la production !

Not getting Involved
Creative Commons License photo credit : TarikB

Dans la folie qui s’ensuivit, le chef de projet, rouge de colère, tonna.

— Anatole ! Je vous jure que vous allez…
— Un instant ! — J’avais usé de toute mon autorité pour l’interrompre — J’affirme qu’il n’y a qu’un seul coupable dans cette pièce. Et ce coupable n’est pas Anatole.

Stupéfaction.

— Monsieur Frédéric B., chef de projet du site www.….com, je vous accuse d’avoir confié les accès de votre plate-forme de production à un stagiaire inexpérimenté. Je vous accuse de l’avoir laissé travailler sur un projet sans l’avoir formé à vos méthodes. Je vous accuse de ne pas avoir mis en place de procédure systématique de vérification ni de validation de son travail par ses pairs plus expérimentés. Enfin, au nom d’Ada, sainte patronne des geeks et codeurs, je vous accuse de contribuer à la médiocrité logicielle générale, en omettant de mettre en place et de promouvoir des bonnes pratiques de développements. Arrêtez-le !

Deux gorilles de la sécurité se saisirent du félon, et l’emmenérent tandis que ses cris de rage et menaces de vengeances raisonnaient dans les couloirs.

Quand le silence revint, je savourai les regards admiratifs qui se posaient sur moi. Ma cliente ne put s’empêcher de me demander :

— Mais comment avez-vous su ? Pour la livraison en production ?
— C’est simple, ma chère. Lorsque j’ai réalisé que certains fichiers n’étaient pas synchrones entre les différents environnements, j’ai fouillé pour en découvrir l’origine. J’ai remarqué que certaines modifications apportées récemments sur le svn se trouvaient bien sur la préproduction, mais jamais sur la production. En revanche, d’autres commits avaient été directement reportés sur la production, mais étaient absent de la production. Et il se trouve que ces commits anormaux avaient tous un même auteur : Monsieur Anatole P., ici présent.

En remontant dans l’historique, j’ai tout de suite vu que ses premières contributions étaient trés récentes. J’ai évidemment pensé à un stagiaire.
— Votre analyse est limpide.
— Élémentaire, même. Cette affaire prouve une fois de plus l’une des Grandes Lois Immuables des TIC.
— Laquelle ?
— Il n’existe pas d’anomalie, quelle qu’elle soit, dans laquelle aucun stagiaire ne soit impliqué.
— Écoutez, monsieur Jouannic. Vous nous avez démontré que vous êtes un homme capable. Nous avons une place de chef de projet à pourvoir. Seriez-vous disponible ?
— Désolé, m’dame, mais ça ne m’intéresse pas. Indépendant je suis, indépendant je reste. Vous savez, détective freelance, ce n’est pas un métier, c’est un mode de vie.
— Bon. Si vous changez d’avis, n’hésitez pas à me contacter.
— J’y penserai. Je vous enverrai ma facture d’ici demain.

Tout en m’installant derrière le volant ma fidèle guibarde, je sortis mon agenda et vérifiai mon emploi du temps du reste de la semaine. Rien. Parfait, j’allais pouvoir partir en vacances quelques jours. Je l’avais bien mérité.

– FIN –

Bonne année 2010 !

Christmas coder
Creative Commons License photo credit : teeboo2734

Chères lectrices, chers lecteurs,

Merci de votre fidélité et de votre intérêt. Je vous souhaite à toutes et à tous une excellente année 2010 pleine de rêves, de projets, d’accomplissements, de santé, de bonheur, d’amour, de sexe, de php, de web, et de tout un tas d’autres trucs plaisants qu’il serait fastidieux de lister ici.

Comme disait je ne sais plus qui : « Que vos mémoires vivent, et que vos disques durent ».

Sur ce, je retourne faire la fête (je plaisante, c’est un billet programmé. Je suis geek mais quand même).