Exporter des données au format Excel (.xls)

Nous allons voir comment exporter des données au format Excel. Nous imaginerons que cette fonctionnalité sera réservée à l’administrateur du site. C’est l’occasion d’aborder trois points intéressants :

  • L’utilisation d’un Helper, très inspiré de celui disponible sur la Bakery mais avec quelques améliorations
  • La manipulation de fichiers avec la classe File
  • La protection de fichiers destinés au téléchargement grâce à une Vue de type Media

1. La demande

Notre client nous impose les contraintes suivantes :
  • pouvoir exporter la liste des membres inscrits sur son site sous forme de fichier Excel
  • l’application doit stocker tous les exports effectués
  • chaque liste ne doit pouvoir être téléchargée que par un administrateur

2. Les actions du Contrôleur Members

Nous déterminons les actions dont nous avons besoin pour satisfaire la demande :

  • adminexportsindex : liste des fichiers Excel existants
  • adminexport : collecte des membres inscrits et enregistrement sous forme de fichier Excel
  • adminexportdownload : téléchargement d’un fichier existant
  • adminexport_delete : suppression d’un fichier d’export

Le préfixe admin_, associé à la mise en place de la restriction d’accès avec le composant Auth, nous garantit que seul un administrateur logué peut accèder à ces quatre actions.

2.1 Liste des fichiers existants

Nous devons veiller à ce que les fichiers qui seront créés ne soient pas directement accessibles par un utilisateur non logué comme administrateur. Le meilleur moyen est d’enregistrer ces fichiers dans un répertoire en dehors de la racine de l’hébergement, et de gérer leur téléchargement grâce à une Vue de type Media. Mais commençons par lister les fichiers disponibles, que nous placerons dans le répertoire {app}/exports (donc hors du webroot) :

// {app}/controllers/members_controller.php
function admin_exports_index()
{
	App::import('Core', 'Folder');
 
	$dir = new Folder('..'.DS.'exports');
 
	$data = $dir->find('.+\.xls');
 
	rsort($data);
 
	$this->set(compact('data'));
}

Cette action importe la classe de gestion de répertoires fournie avec Cake, Folder. Nous l’instancions avec le chemin d’accès au répertoire de destination, et nous utilisons la méthode Folder::find avec une expression régulière pour trouver tous les fichiers dont l’extension est « .xls ». Nous classons la liste de fichiers trouvés par ordre alphabétique inverse, et passons le tableau à la Vue. La vue correspondante est très simple :

// {app}/members/admin_exports_index.ctp
<h3>Historique des exports</h3>
 
<table>
	<thead><?php
	echo $html->tableHeaders(
		array("Fichier", "Action")
	); ?></thead>
 
	<tbody>
	<?php foreach($data as $row): ?>
		<tr>
			<td><?php echo $row; ?></td>
			<td>
				<?php echo $html->link("Télécharger", 'export_download/'.$row); ?> 
				<?php echo $html->link("Supprimer", 'export_delete/'.$row, null, "Etes-vous sûr de vouloir supprimer ?"); ?> 
			</td>
		</tr>
	<?php endforeach; ?> 
	</tbody>
</table>
 
<p><?php echo $html->link("Créer un nouvel export", 'export'); ?></p>

Nous parcourons la liste des fichiers trouvés en faisant un lien vers les actions adminexportdownload et adminexportdelete, auxquelles nous passons en paramètre le nom du fichier. Nous terminons par un lien vers le script de création d’un nouvel export.

2.2 Création d’un nouvel export

Cette action va utiliser le Helper Xls suivant :

// {app}/views/helpers/xls.php
<?php
class XlsHelper extends AppHelper
{
	/**
	 * Buffer. Chaque indice du tableau contient une ligne du fichier.
	 *
	 * @var array
	 */
	var $rows = array();
 
	/**
	 * Ajoute un nombre
	 *
	 * @param int $row Abscisse
	 * @param int $col Ordonnée
	 * @param int $val Valeur numérique
	 * @return string Valeur encodée
	 */
	function addNumber($row, $col, $val)
	{
	    $out  = pack("sssss", 0x203, 14, $row, $col, 0x0);
	    $out .= pack("d", $val);
 
	    return $out;
	}
 
	/**
	 * Ajoute une chaine de caractères
	 *
	 * @param int $row Abscisse
	 * @param int $col Ordonnée
	 * @param int $val Chaine de caractères
	 * @return string Valeur encodée
	 */
	function addString($row, $col, $val)
	{
	    $length = strlen($val);
 
	    $out  = pack("ssssss", 0x204, 8 + $length, $row, $col, 0x0, $length);
	    $out .= $val;
 
	    return $out;
	}
 
	/**
	 * Ajoute une ligne dans le buffer
	 *
	 * @param array $data Tableau de valeurs
	 */
	function addRow($data = array())
	{
		$out = '';
 
		$row = count($this->rows);
 
		foreach($data as $col => $val)
		{
			if(is_numeric($val))
			{
				$out .= $this->addNumber($row, $col, $val);
			}
			else
			{
				$out .= $this->addString($row, $col, "{$val}");
			}
		}
 
		$this->rows[] = $out;
	}
 
	/**
	 * Retourne buffer encodé, avec l'entête et la fin de fichier.
	 *
	 * @return string Contenu du fichier
	 */
	function render()
	{
		$out  = pack("ssssss", 0x809, 0x8, 0x0, 0x10, 0x0, 0x0);
		$out .= join('', $this->rows);
		$out .= pack("ss", 0x0A, 0x00);
 
		return $out;
	}
}
?>

Nous prenons soin d’y faire appel dans le Contrôleur Members :

// {app}/controllers/members_controller.php
var $helpers = array('Xls');
 
function admin_export()
{
	$data = $this->Member->find('all');
 
	$this->set(compact('data'));
 
	$filename  = 'export_' . strftime('%Y-%m-%d') . '.xls';
 
	$this->autoLayout = false;
 
	App::import('Core', 'File');
 
	$file = new File('..'.DS.'exports'.DS.$filename, true);
	$file->write($this->render());
	$file->close();
 
	$this->Session->setFlash("Nouveau fichier disponible.", 'message_ok');
	$this->redirect($this->referer());
}

Voyons pas à pas l’exécution de cette action :

  • nous commençons par récupérer la liste des membres inscrits et la passons à la Vue ;
  • nous créons le nom du fichier à partir de la date du jour ;
  • nous empêchons CakePHP d’ajouter le layout autour de la Vue avec $this->autoLayout = false; ;
  • nous importons la classe de gestion de fichiers, File, fournie avec CakePHP ;
  • nous créons le nouveau fichier en mode écriture (attention si le fichier existe déjà il sera écrasé) ;
  • nous écrivons le résultat de la vue dans le fichier ;
  • nous redirigeons sur l’index des fichiers avec un message de succès.
Voyons la Vue, qui correspond en fait au contenu du fichier Excel :

// {app}/views/members/admin_export.ctp
 
// Entêtes
$xls->addRow(array("ID", "Email", "Date d'inscription"));
 
// Données
foreach($data as $row)
{
	$xls->addRow(array(
		$row['Member']['id'],
		$row['Member']['email'],
		$row['Member']['created']
	));
}
 
// Rendu
echo $xls->render();

2.3 Télécharger un fichier

Nous l’avons vu, nos fichiers sont enregistrés en dehors du webroot, rendant impossible le téléchargement direct. Nous allons utiliser une Vue de type Media pour servir le fichier à télécharger :

// {app}/controllers/members_controller.php
function admin_export_download($filename)
{
	$this->view = 'Media';
 
	$params = array(
  		'path'      => 'exports' . DS,
	  	'id'        => $filename,
	  	'name'      => substr($filename, 0, strpos($filename, '.xls')),
	  	'extension' => 'xls',
	  	'download'  => true
	);
 
	$this->set($params);
}

La Vue Media attend la variable $params, un tableau dont les entrées sont les suivantes :

  • path : chemin d’accès au répertoire où se trouvent les fichiers. Notons qu’ici nous partons de la racine de l’application et non du webroot, et qu’il faut terminer par le séparateur de répertoires ;
  • id : nom réel du fichier sur le serveur
  • name : nom final du fichier proposé au téléchargement, sans l’extension. Nous choisissons de garder le nom réel du fichier, mais nous aurions tout aussi bien pu imposer n’importe quel autre nom ;
  • extension : l’extension du fichier proposé au téléchargement. Là encore nous pourrions imposer une autre extension ;
  • download : si true, force le téléchargement (ouverture d’une fenêtre du navigateur qui va proposer d’ouvrir ou de télécharger le fichier). Si false, le navigateur va essayer de charger l’application locale associée au type du fichier.
Nous ne créons pas de Vue associée à cette action, CakePHP s’en charge.

2.4 Suppression d’un fichier

Il ne nous reste qu’à proposer la possibilité de supprimer un fichier, là encore tirons parti de la classe File :

// {app}/controllers/members_controller.php
function admin_delete_file($filename)
{
	App::import('Core', 'File');
 
	$file = new File('..' . DS . 'exports' . DS . $filename);
 
	if(!$file->delete())
	{
		$this->Session->setFlash("Impossible de supprimer le fichier '{$filename}'.", 'message_error');
	}
	else
	{
		$this->Session->setFlash("Fichier '{$filename}' supprimé.", 'message_ok');
	}
 
	$this->redirect($this->referer());
}
Pierre-Emmanuel Fringant

Articles connexes

Commentaires

[...] Exporter des données au format Excel (.xls) [...]

Bonjour et merci pour ce tuto, je l’ai adapté pour créer et télécharger le fichier en une seule passe.

Est-ce possible de supprimer le fichier juste après l’avoir téléchargé ?

J’ai également un problème d’encodage des caractères, mais je ne sais pas si ça vient de l’export ou de la configuration de mon OpenOffice.

merci

Salut,

Tuto pratique… mais perfectible à mon sens.

D’abord on peut très bien écrire les fichiers dans un répertoire du webroot et donc proposer un lien direct de téléchargement, sans avoir besoin de créer la fonction de download !

Voici ce que je fais :

$file = new File(WWW_ROOT.'files/exports/'.$filename, true);

Ensuite, comme Elianora, j’ai de nombreux problèmes d’accents dans le fichier Excel généré et je n’utilise pas OpenOffice. Apparemment, par défaut, c’est encodé en iso…

Enfin, il me semblerait utile de pouvoir faire chaque nouvel export, en prenant comme point de départ le premier inscrit après la date du précédent export, au lieu de récupérer tous les enregistrements de la table à chaque fois.

Je suis en train de réfléchir à un système pour conserver quelque part le dernier inscrit de l’export qui vient d’être effectué, je reviendrai ici pour expliquer comment je m’y prends.

La contrainte ici était que les fichiers créés ne devaient surtout pas être accessibles librement (donc pas dans le webroot).

Je n’ai pas constaté le problème d’accents, l’export ici ne concernant que des adresses email. Si tu trouves la parade, n’hésite pas !

Bien évidemment la logique de collecte des données avant l’export reste à la charge du développeur, ce n’était là qu’un exemple simplifié. Le coeur du sujet était avant tout l’export au format excel et la protection contre le download direct.

Merci pour tes retours en tout cas, toujours intéressants.

Oui, désolé pour mon emportement, en relisant ton tuto et juste après avoir déposé mon commentaire, je me suis aperçu que c’était volontaire de créer les exports hors de la racine web ;o)

Bon, sinon, j’ai trouvé une première astuce pour les accents : dans la vue adminexport, j’ai simplement ajouté des utf8decode() autour des données susceptibles de contenir des accents ! Il y a d’autres techniques, visiblement en envoyant des headers dans la vue qui gère le download, mais comme je ne l’utilise pas, j’ai pas été plus loin.

Je cherche maintenant une manière élégante de ne pas reprendre toute la base à chaque export, mais de repartir à la dernière ligne exportée précédemment. J’indiquerai ici le résultat de mes recherches ;o)

Après de nombreux essais infructueux pour lire le fichier Excel créé lors du dernier export (trop de problème avec le format XLS notamment une fois mis en prod sur du Linux), j’ai choisi une petite astuce qui vaut ce qu’elle vaut !

En fait, à chaque export, j’enregistre le dernier id dans un fichier texte et si l’utilisateur souhaite ensuite faire un export partiel, je lis cet id dans le fichier texte et je l’utilise comme condition au find() :

if ($type === 'partial') {
        $last_id = file_get_contents(WWW_ROOT.'files/exports/last_id.txt');
        $data = $this->Inscrit->find('all',array('conditions'=>'id > '.$last_id));
    }
    else {
        $data = $this->Inscrit->find('all');
    }
}

Un immense MERCI pour ce tuto qui est vraiment très utile, je n’aurai vraiment pas su par où commencer tout seul. (Je n’ai pas fini de code mon extraction mais ça commence déjà à fonctionner, c’est rassurant !!)

Bonjour, Le fichier xls créé ne contient pas les données qui devrait y être mais des mots qu’on ne peut pas lire; alors que j’ai bien suivi le tuto; quelqu’un peut m’aider svp; merci

Participez

Pour insérer une portion de code, utilisez <pre lang="php">...</pre>