Gestion d’une galerie photo avec swfUpload dans l’admin

Je vous propose de voir une gestion de galerie photo pour l’administration, telle que je l’ai abordée dans un de mes derniers projets.

Pour vous situer, il s’agit d’un site de vente en ligne.
Qui dit vente dit produits, et qui dit produits dit photos 😀

L’idée est de gérer les photos d’un produit directement depuis sa page d’édition de l’admin générator.

upload1

upload2

upload3

On a choisi de gérer l’upload avec swfUpload qui gére un liste d’attente pour l’upload de plusieurs fichiers. Et l’upload en background qui ne gèle pas la page, ca fait plus web2.0 :p
(http://code.google.com/p/swfupload/)

J’ai téléchargé le SWFUpload v2.2.0.1 Samples.zip et j’ai utilisé les fichiers du répertoire « simpledemo » que j’ai réparties comme suis.
(Le fichier css d’origine s’appelait default.css, je l’ai renommé pour des raisons évidentes.)

  • /web/swf/swfupload.swf
  • /web/css/swfUpload.css
  • /web/js/swfupload.js
  • /web/js/swfupload.queue.js
  • /web/js/fileprogress.js
  • /web/js/handlers.js
  • /web/images/XPButtonUploadText_61x22.png

Un coup d’oeil rapide à la partie du schema.yml qui nous intéresse. Rien de transcendant, un objet Produit avec quelques champs types et l’objet ProduitPhoto qui lui est lié. Sur l’objet ProduitPhoto, un filename et un booléen pour définir l’image par défaut du produit.

...
Produit:
  tableName:        monprojet_produit
  actAs:
    Timestampable:  ~
  columns:
    id:             { type: integer(4), unsigned: true, primary: true, autoincrement: true }
    nom:            { type: string(255), notnull: true }
    description:    { type: clob }
    prix:           { type: decimal, scale: 2 }
 
ProduitPhoto:
  tableName:        monprojet_produit_photo
  columns:
    id:             { type: integer(4), unsigned: true, primary: true, autoincrement: true }
    produit_id:     { type: integer(4), unsigned: true }
    filename:       { type: string(255), notnull: true }
    is_default:     { type: boolean, default: false }
  relations:
    Produit:
      local:        produit_id
      foreign:      id
      foreignAlias: Photos
      onDelete:     CASCADE
...

On génére l’admin-generator pour l’objet produit.

?View Code CONSOLE
./symfony doctrine:generate-admin backend Produit

Et c’est partie !

Pour rajouter cette gestion des images dans l’ecran d’édition des produits il faut rajouter un partial à la vue edit.
(On la rajoute uniquement sur l’edit, car sur l’écran create, l’objet n’est pas encore persisté en base et donc on ne peut pas lui rattacher des objets ProduitPhoto)

Le fonctionnement est le suivant. Le partial général où est inclus et configuré swfUpload (_photoUpload.php). Un parial _photoListe.php inclus dans _photoUpload.php qui servira pour raffraichir la liste en ajax (avec jQuery). Et un dernier partial _ajaxPhotoDelete.php qui ne sert qu’a retourner l’animation à faire lors de la suppression d’une photo.

On commence par positionner le partial _photoUpload.php dans le generator.yml en contexte edit.

generator:
  class: sfDoctrineGenerator
  param:
    model_class:           Produit
    theme:                 admin
    non_verbose_templates: true
    with_show:             false
    singular:              ~
    plural:                ~
    route_prefix:          produit_produit
    with_doctrine_route:     1
 
    config:
      actions: ~
      fields:
        created_at:        { label: Date de création, date_format: dd/MM/yyyy }
        updated_at:        { label: Date de modification, date_format: dd/MM/yyyy }
        nom:               { label: Nom }
        description:       { label: Déscription }
      list:
        title:             Liste des produits
        display:           [=id, =nom]
      filter: ~
      form: ~
      edit:
        title:             Modifier un produit
        display:
          "Produit":       [id, nom, description]
          "Photos":        [_photoUpload]
          "Dates":         [created_at, updated_at]
      new:
        title:             Ajouter un produit
        display:
          "Produit":       [id, nom, description]
          "Dates":         [created_at, updated_at]

Et on va se pencher plus en détail sur ce partial _photoUpload.php.

<?php use_stylesheet('swfUpload.css') ?>
 
<?php use_javascript('jquery.js') ?>
<?php use_javascript('jquery-ui.js') ?>
<?php use_javascript('swfupload.js') ?>
<?php use_javascript('swfupload.queue.js') ?>
<?php use_javascript('fileprogress.js') ?>
<?php use_javascript('handlers.js') ?>
 
<div id="pictures_list" class="sf_admin_form_row">
  <?php include_partial('produit/photoListe', array('photos' => $form->getObject()->getPhotos())) ?>
</div>
 
<div id="picture_upload" class="sf_admin_form_row">
  <div class="fieldset flash" id="fsUploadProgress"><span class="legend">File d'attente.</span></div>
  <div id="divStatus">0 fichier uploadé.</div>
  <div>
    <span id="spanButtonPlaceHolder"></span>
    <input id="btnCancel" type="button" value="Cancel All Uploads" onclick="$.swfUpload.cancelQueue();" disabled="disabled" style="margin-left: 2px; font-size: 8pt; height: 29px;" />
  </div>
</div>
 
<script type="text/javascript">
 
(function($) {
 
  $('div.actions a.default').live('click', function(event) {
    event.preventDefault();
    $.post(
      $(this).attr('href'),
      { },
      function(data) {
        $('#pictures_list').html(data);
      }
    );
  });
 
  $('div.actions a.delete').live('click', function(event) {
    event.preventDefault();
    if(confirm('Etes vous sur de vouloir supprimer cette photo ?')) {
      $.post(
        $(this).attr('href'),
        { },
        function(data) {
          eval(data);
        }
      );
    }
  });
 
  myQueueComplete = function(numFilesUploaded) {
    var status = document.getElementById("divStatus");
    status.innerHTML = numFilesUploaded + " fichier" + (numFilesUploaded === 1 ? "" : "s") + " uploadé" + (numFilesUploaded === 1 ? "" : "s") + ".";
 
    $.post(
      '<?php echo url_for('produit_ajax_photo_liste', $form->getObject()) ?>',
      { },
      function(data) {
        $('#pictures_list').html(data);
      }
    );
  };
 
  jQuery.swfUpload = new SWFUpload({
    flash_url: "/swf/swfupload.swf",
    upload_url: "<?php echo url_for('produit_photo', $form->getObject()) ?>",
    post_params: {"<?php echo ini_get('session.name') ?>" : "<?php echo session_id(); ?>"},
    file_size_limit: "100 MB",
    file_types: "*.jpg;*.gif;*.png",
    file_types_description: "Fichiers image",
    file_upload_limit: 10,
    file_queue_limit: 0,
    file_post_name: 'photo',
    custom_settings: {
      progressTarget: "fsUploadProgress",
      cancelButtonId: "btnCancel"
    },
    debug: false,
    // Button settings
    button_image_url: "/images/XPButtonUploadText_61x22.png",
    button_width: "61",
    button_height: "22",
    button_placeholder_id: "spanButtonPlaceHolder",
    // The event handler functions are defined in handlers.js
    file_queued_handler: fileQueued,
    file_queue_error_handler: fileQueueError,
    file_dialog_complete_handler: fileDialogComplete,
    upload_start_handler: uploadStart,
    upload_progress_handler: uploadProgress,
    upload_error_handler: uploadError,
    upload_success_handler: uploadSuccess,
    upload_complete_handler: uploadComplete,
    queue_complete_handler: myQueueComplete
  });
 
})(jQuery);
 
</script>

Il faut penser à inclure les différents éléments nécessaires le css et les tout les js. Je les ai inclus ici pour bien les mettre en évidence.
Après il suffit de positionner les différents éléments html nécessaires au fonctionnement de swfUpload.
Et enfin le javascript pour initialiser swfUpload. J’utilise jQuery pour gérer l’ajax, j’ai donc adapté l’initialisation de swfUpload pour la rendre « jQuery friendly » :p

L’initialisation de swfUpload est assez standard, avec toute une tripoté de paramètres pour définir : le type de fichier accepté, configurer l’apparence du bouton et configurer tous les retour d’événements qu’il est possible d’utiliser.
J’ai laissé les gestionnaires d’événements par défaut à l’exception de l’événement de fin d’upload (myQueueComplete) que j’ai redéfini pour faire un appel ajax et recharger le partial _photoListe.php.
J’attire votre attention sur le parametre post_params auquel on passe le session name et le session_id, on verra un peu plus bas le pourquoi du comment 😉

Le partial _photoListe.php, rien de spécial. Un listing des photos du produit avec 2 liens pour supprimer ou définir comme image par défaut, les 2 faisant appel à une action en ajax.
(NB: On retrouve le Helper Thumb de thomas pour les miniatures des photos)

<?php use_helper('Thumb') ?>
 
<?php if($photos->count() > 0): ?>
<?php foreach( $photos as $photo ): ?>
<div id="photo-<?php echo $photo->getId()?>" class="picture">
  <?php echo showThumb(
  $photo->getFilename(),
  'produits',
  array(
    'height' => 120,
    'width'  => 120,
    'alt'    => $photo->getProduit()->getNom(),
    'title'  => $photo->getProduit()->getNom(),
    'border' => 0
  ),
  'center',
  'produit-defaut.jpg') ?>
  <div class="actions">
    <?php if($photo->isDefault()): ?>
    <strong><img src="/images/backend/star.png" align="left" /> Par défaut</strong>
    <?php else: ?>
    <ul>
      <li><a href="<?php echo url_for('produit_photo_ajax_default', $photo) ?>" class="default"><img src="/images/backend/star.png" align="left" /> Par défaut</a></li>
      <li><a href="<?php echo url_for('produit_photo_ajax_delete', $photo) ?>" class="delete"><img src="/images/backend/cross.png" align="left" /> Supprimer</a></li>
    </ul>
    <?php endif; ?>
  </div>
</div>
<?php endforeach; ?>
<div class="clear"></div>
<?php else: ?>
<p>Aucune photo pour l'instant.</p>
<?php endif; ?>

Le partial _ajaxPhotoDelete.php, c’est juste l’effet jQuery-ui pour faire disparaitre la div de la photo qui viens d’être supprimée.

?View Code JAVASCRIPT
$('#photo-<?php echo $photo_id ?>').effect('drop');

Un petit tour sur le routing.yml pour défnir les différentes routes utilisées.

...
produit_photo:
  url:     /produit/:id/upload
  class:   sfDoctrineRoute
  options: { model: Produit, type: object }
  param:   { module: produit, action: upload }
  requirements:
    sf_method: [get, post]
 
produit_photo_ajax_default:
  url:     /produit_photo/:id/default
  class:   sfDoctrineRoute
  options: { model: ProduitPhoto, type: object }
  param:   { module: produit, action: ajaxPhotoDefault }
  requirements:
    sf_method: [post]
 
produit_photo_ajax_delete:
  url:     /produit_photo/:id/delete
  class:   sfDoctrineRoute
  options: { model: ProduitPhoto, type: object }
  param:   { module: produit, action: ajaxPhotoDelete }
  requirements:
    sf_method: [post]
 
produit_ajax_photo_liste:
  url:     /produit_photo/:id/list
  class:   sfDoctrineRoute
  options: { model: Produit, type: object }
  param:   { module: produit, action: ajaxPhotoListe }
  requirements:
    sf_method: [post]
...

On utilise la classe formulaire qui va gérer l’upload.
Il faut désactiver le csrf car l’envoie n’est pas effectué par le formulaire mais par l’animation flash.

<?php
 
class ProduitPhotoForm extends BaseProduitPhotoForm
{
  public function configure()
  {
    $this->widgetSchema['produit_id'] = new sfWidgetFormInputHidden();
    $this->widgetSchema['is_default'] = new sfWidgetFormInputHidden();
    $this->widgetSchema['filename'] = new sfWidgetFormInputFile(array());
 
    $this->validatorSchema['filename'] = new sfValidatorFile(array(
      'required'   => true,
      'path'       => sfConfig::get('sf_upload_dir').'/produits/source',
      'mime_types' => 'web_images',
      'max_size'   => 10485760
    ), array(
      'max_size' => 'Fichier trop gros (10Mo maximum).'
    ));
 
    $this->disableCSRFProtection();
  }
}

Et enfin l’action.class.php qui orchestre tout ca.
Au niveau de l’action executeUpload il faut créer « à la main » le tableau des paramètres à passer au bind.

<?php
 
require_once dirname(__FILE__).'/../lib/produitGeneratorConfiguration.class.php';
require_once dirname(__FILE__).'/../lib/produitGeneratorHelper.class.php';
 
/**
 * produit actions.
 *
 * @package    
 * @subpackage produit
 * @author     
 * @version    SVN: $Id: actions.class.php 12474 2008-10-31 10:41:27Z fabien $
 */
class produitActions extends autoProduitActions
{
 
  public function executeUpload(sfWebRequest $request)
  {
    $this->produit = $this->getRoute()->getObject();
    $this->forward404unless($this->produit);
 
    $this->form = new ProduitPhotoForm();
 
    $values = array(
      'produit_id' => $this->produit->getId(),
      'is_default' => ($this->produit->getPhotos()->count() > 0) ? false : true,
      'filename'   => $request->getFiles('photo')
    );
 
    $this->form->bind($values, $values);
 
    if ($this->form->isValid())
    {
      $photo = $this->form->save();
 
      $return = 'ok';
    }
    else
    {
      $return = 'ko';
    }
 
    return $this->renderText($return);
  }
 
 
  /**
   * ajax pour definir une photo par defaut
   *
   * @param sfWebRequest $request
   */
  public function executeAjaxPhotoDefault(sfWebRequest $request)
  {
    if($request->isXmlHttpRequest())
    {
      $photo = $this->getRoute()->getObject();
      $produit = $photo->getProduit();
 
      $old_default = $produit->getPhotoDefault();
 
      $produit->setPhotoDefaut($photo->getId());
 
      return $this->renderPartial('produit/photoListe', array('photos' => $produit->getPhotos()));
    }
    else
    {
      $this->redirect404();
    }
  }
 
 
  /**
   * ajax pour effacer une photo
   *
   * @param sfWebRequest $request
   */
  public function executeAjaxPhotoDelete(sfWebRequest $request)
  {
    if($request->isXmlHttpRequest())
    {
      $photo = $this->getRoute()->getObject();
      $photo_id = $photo->getId();
      $photo->delete();
 
      return $this->renderPartial('produit/ajaxPhotoDelete', array('photo_id' => $photo_id));
    }
    else
    {
      $this->redirect404();
    }
  }
 
 
  /**
   * ajax pour avoir la liste des photos
   *
   * @param sfWebRequest $request
   */
  public function executeAjaxPhotoListe(sfWebRequest $request)
  {
    if($request->isXmlHttpRequest())
    {
      $produit = $this->getRoute()->getObject();
 
      return $this->renderPartial('produit/photoListe', array('photos' => $produit->getPhotos()));
    }
    else
    {
      $this->redirect404();
    }
  }
 
 
}

Produit.class.php

<?php
 
class Produit extends BaseProduit
{
 
  public function setPhotoDefaut($photoId)
  {
    Doctrine_Query::create()
    ->update('ProduitPhoto p')
    ->set('p.is_default', '?', false)
    ->where('p.produit_id = ?', $this->getId())
    ->execute();
 
    Doctrine_Query::create()
    ->update('ProduitPhoto p')
    ->set('p.is_default', '?', true)
    ->andWhere('p.id = ?', $photoId)
    ->execute();
 
    return true;
  }
 
  public function getPhotoDefault()
  {
    return Doctrine::getTable('ProduitPhoto')->getDefault($this->getId());
  }
 
}

ProduitPhotoTable.class.php

<?php
 
class ProduitPhotoTable extends Doctrine_Table
{
 
  public function getDefault($produit_id)
  {
    return $this->createQuery('c')
      ->andWhere('c.produit_id = ?', $produit_id)
      ->andWhere('c.is_default = ?', true)
      ->fetchOne();
  }
 
}

ProduitPhoto.class.php

<?php
 
class ProduitPhoto extends BaseProduitPhoto
{
 
  public function isDefault()
  {
    return (bool) $this->getIsDefault();
  }
 
}

Le problème !

A ce stade, tout est fini et en place pour que ca marche. Hors ca ne marche pas… Pourquoi ? Je rapelle le contexte, on est dans l’admin donc on est identifié et c’est là le problème. En effet swfUpload est une animation flash contenu dans le page mais qui va initialiser des requêtes http parallèles à celle utilisée par le navigateur. Et donc lorsque swfUpload se pointe à l’url « produit_photo » pour initialisé son upload, il se prend une belle erreur 403…

Le problème est connu des concepteurs de swfUpload et la solutions qu’ils préconisent est de forcé l’identifiant de session dans le script d’upload en faisant un session_id($session_id). C’est pourquoi on le fait passer dans la variable « post_params » à l’initialisation de swfUpload.

Pour faire la même chose dans symfony on ne peut pas le faire dans une action, car la session est déjà initialisée à ce niveau. On est obligé d’aller modifié le factories.yml et la calsse la classe sfSessionStorage pour aller forcer l’identifiant de session dans le cas ou on fait appel à l’action upload.

C’est un tip que j’ai trouvé sur le forum symfony qui était pour la version 1.0, et qui fonctionne toujours à la différence que le contexte n’est plus passé en paramètre dans la 1.2 et donc il faut aller le charger.

factories.yml

...
all:
...
  storage:
    class: mySessionStorage
    param:
      session_name: weezobackend
...

mySessionStorage.calss.php

<?php
 
class mySessionStorage extends sfSessionStorage
{
  public function initialize($options = null)
  {
    $context = sfContext::getInstance();
 
    //Shitty work-around for swfuploader
    if( $context->getActionName() == "upload")
    {
      $sessionName = $parameters["session_name"];
 
      if($value = $context->getRequest()->getParameter($sessionName))
      {
        session_name($sessionName);
        session_id($value);
      }
    }
 
    parent::initialize($options);
  }
}

Ça y est, cette fois ça marche !
Et vous avez une gestion d’images directement dans la fiche du produit qui est assez user-friendly 😉

Merci d’avoir lu jusqu’au bout :p
Je reste à votre disposition pour toute questions ou suggestions 😉
@bientôt !

Partagez cet article