Importer des données de disponibilités pour Availability Calendars depuis un système externe via Migrate

Le module Availability Calendars permet d'afficher des disponibilités sous la forme d'un calendrier, par exemple pour des hébergements ou du prêt de matériel. Dernièrement, on m'a chargé d'importer des données de disponibilité depuis un système externe (en l'occurrence, un flux Tourinsoft) sous la forme de données compréhensibles par Availability Calendars, en utilisant Migrate pour Drupal 7 (voir ma présentation sur la migration de données avec Drupal). Cette tâche étant passablement plus pénible que prévu, et aucune documentation sur ce thème n'existant à ma connaissance à l'heure actuelle, j'essaie par ce billet de remédier à cette situation. Une connaissance du fonctionnement général de Migrate est supposée, n'hésitez pas à vous référer à la documentation officielle, qui est un point fort du module.

Handler de champ

La plupart du temps, il suffit de spécifier une destination et un mapping de champs pour que Migrate intègre comme par magie les données dans l'entité de destination. L'ennui, c'est qu'ici nous avons à traiter des données complexes, que Migrate ne sera pas capable de traduire en quelque chose d'intelligible pour le type de champ cible. C'est le rôle d'un handler de champ ; nous sommes contraints d'en écrire un pour le champ de type availability_calendar.

Cette classe doit être enregistrée par Migrate ; pour ce faire, dans le hook_migrate_api() de votre fichier MODULE.migrate.inc, renseigner les métadonnées suivantes :

/**
  * Implements hook_migrate_api().
  */
function MODULE_migrate_api() {
  $api = array( 'api' => 2,
    // [...]
    'field handlers' => array(
      'AvailabilityCalendarFieldHandler',
    ),
  );
  return $api;
} 

En Drupal 7, cette classe doit être chargée automatiquement en ajoutant cette ligne à votre fichier .info :

files[] = migrate_field_handlers.inc

Voici le contenu de ce fichier :

<?php

/**
  * @file
  * Custom Migrate field handlers.
  *
  * @see https://www.drupal.org/node/1429096.
  */

/**
  * Migrate availability data to availability_calendar fields.
  */
class AvailabilityCalendarFieldHandler extends MigrateFieldHandler {
  /**
    * Register availability_calendar type.
    */
  public function __construct() {
    $this->registerTypes(array('availability_calendar'));
  }

  /**
    * Prepare data accordingly for the field API.
    */
  public function prepare($entity, array $field_info, array $instance, array $values) {
    module_load_include('inc', 'availability_calendar', 'availability_calendar.widget');
    // Les deux lignes suivantes sont reprises de l'exemple et ne servent à
    // rien dans l'état actuel des choses, ne cherchez pas.
    $migration = Migration::currentMigration();
    $arguments = (isset($values['arguments'])) ? $values['arguments'] : array();
    $language = $this->getFieldLanguage($entity, $field_info, $arguments);
    $delta = 0;
    $return = array();
    // Pour compatibilité avec le mode fonctionnement d'Availability Calendars.
    $element = array(
      'availability_states' => array(
        '#options' => availability_calendar_get_states(),
      ),
      'availability_calendar' => array(
        '#settings' => $instance['widget']['settings'],
      ),
      '#field_name' => $instance['field_name'],
      '#language' => $language,
    );
    foreach ($values as $value) {
      $element['#delta'] = $delta;
      $cid = $this->prepareCalendar($value, $element, $entity);
      if (!$cid) {
        return;
      }
      $return[$language][$delta] = array(
        'enabled' => 1,
        'name' => '',
        'cid' => $cid,
      );
      $delta++;
    }

    return $return;
  }

  /**
    * Forcer la création de l'entité embarquant les disponibilités.
    *
    * L'approche « hybride » d'Availability Calendars ne permet pas de se
    * contenter des seules fonctionnalités habituelles de la Field API, il faut
    * en même temps forcer la création du calendrier.
    *
    * @see availability_calendar_field_widget_month_form_validate()
    * @see availability_calendar_field_attach_submit_inc()
    */
  protected function prepareCalendar($dates, $element, $entity) {
    $changes = array();
    $lines = explode("\n", $dates);
    foreach ($lines as $line) {
      if (!empty($line)) {
        $change = availability_calendar_field_widget_month_form_validate_line($line, $element);
        if ($change == FALSE) {
          watchdog('tourinsoft', "Disponibilités d'hébergement invalides : %line", array('%line' => $line), WATCHDOG_ERROR); 
          return FALSE;
        }
        $changes[] = $change;
      }
    }
    static $new_cid_count = 0;
    $cid_unique = 'new' . ++$new_cid_count;
    $field_name = $element['#field_name'];
    $language_code = $element['#language'];
    $delta = $element['#delta'];
    $cid = availability_calendar_update_multiple_availability((int) $cid_unique, $changes);
    if (!isset($entity->{$field_name}[$language_code][$delta]['cid']) || $cid != $entity->{$field_name}[$language_code][$delta]['cid']) {
      // Nouveau calendrier : mettre à jour le champ.
      $entity->{$field_name}[$language_code][$delta]['cid'] = $cid;
    }
    return $cid;
  }

}

Une explication s'impose :

  • cette classe doit hériter de MigrateFieldHandler, qui offre le cadre général pour mener à bien cette tâche. Ce fonctionnement est habituel s'agissant du cadre de développement proposé par Migrate, jusqu'ici, tout va bien.
  • Dans le constructeur, on spécifie les types de champs pris en charge par cette classe.
  • La méthode prepare permet d'agir sur les données avant qu'elles ne soient présentées à la Field API. L'objectif est de les structurer sous la forme d'un tableau compréhensible par cette dernière (le tableau $return).
  • On suppose qu'on s'est préalablement arrangé (voir seconde partie de ce billet), pour que les données soient sous la forme d'intervalles, exprimés de la sorte (c'est en fait le format généré lorsqu'on passe par le formulaire du module Availability Calendars) :
    2,2016-01-21,2016-01-23
    4,2016-02-12,2016-02-27
    3,2016-03-10,2016-04-24
    1,2016-06-09,2016-06-11
    Il s'agit en fait des changements exprimés par rapport à l'étant antérieur du calendrier (l'état par défaut étant non communiqué). Le code de l'état est présent en première colonne (les colonnes étant séparées par des virgules) ; ce sont les états spécifiés dans la table availability_calendar_state à l'installation du module. Le fait que ces données soient dynamiques (cf. interface à admin/config/content/availability-calendar) rendrait plus difficile la publication de ce code sous la forme d'un module. Ici, 2 signifie disponible et 3 complet.
  • La difficulté réside dans le fait que l'approche de l'auteur d'Availability Calendars, comme expliqué dans le README, est hybride, et repose sur l'utilisation d'un champ couplé à une entité (contenant effectivement les données de disponibilités), raison pour laquelle on effectue dans dans la méthode prepareCalendar() un traitement permettant de créer une telle entité calendrier, pour ensuite récupérer son identifiant, qui sera stocké dans le champ, ce qui représente une petite entorse par rapport au fonctionnement de base de la field API. Malheureusement, l'auteur n'a semble-t-il pas prévu une création de calendriers autrement qu'au moyen du formulaire d'édition de l'entité (du nœud, par exemple), ce qui fait qu'on est ici obligé de dupliquer du mieux possible du code présent dans availability_calendar_field_widget_month_form_validate() et availability_calendar_field_attach_submit_inc().
  • À noter : ce handler de champ n'importera pas les intervalles périmés, ce comportement est lié aux choix effectués dans la fonction availability_calendar_field_widget_month_form_validate_line() appelée depuis la méthode prepareCalendar() ; le modifier supposerait de dupliquer plus de code.

Intégration à la classe de migration

Une fois ce handler de champ mis en œuvre, il reste à faire en sorte que la classe de migration sache en tirer parti pour extraire les données et les transformer en calendriers de type « Availability Calendar ».

Dans le constructeur de votre classe de migration :

  1. vous devez ajouter le champ de la source de données comportant les disponibilités, dans mon cas :
    $ts_fields = array(
      // [...]
      'PLANNINGETATS' => 'Accommodation availability',
    );
    $this->source = new MigrateSourceList(new TourinsoftListJSON($json_file), new TourinsoftItemJSON($json_file), $fields); 

    Vous n'avez rien à changer concernant la destination (si c'est un nœud, ça reste un nœud, vous ne vous occupez de rien à ce niveau-là), ni le mapping (qui utilise déjà un identifiant particulier, qui n'a rien à voir avec les données considérées ici).

  2. En revanche, il faut bien effectuer la liaison entre les champs de la source et de la destination :
    $this->addFieldMapping('field_ts_disponibilites', 'PLANNINGETATS');

Tout ceci est bien joli, mais bien évidemment de telles données ont toutes les chances d'être exprimées dans des formats complètement différents à la source (Tourinsoft) et à l'arrivée (format attendu par notre handler de champ, voir ci-dessus). Dans mon cas, Tourinsoft stocke les données sous la forme d'une chaîne contenant autant de caractères que de jours de l'année, chaque caractère codant la disponibilité pour le jour correspondant dans l'année (de manière positionnelle, le premier caractère correspondant au 1er janvier, le second au 2 janvier, et ainsi de suite jusqu'au dernier qui représente le 31 décembre, c'est du grand art). Avec Migrate, on va agir dans la méthode prepareRow() de votre classe de migration :

/**
  * Agir sur le format des champs à la source avant la migration.
  */
public function prepareRow($row) {
  parent::prepareRow($row);
  // [...]
  $this->prepareAvailabilityStates($row);
}

/**
  * Conversion des états de disponibilité des hébergements.
  */ 
protected function prepareAvailabilityStates($row) {
  if (!isset($row->PLANNINGETATS)) {
    return;
  }

  // Correspondance des codes des états des réservations, cf. table
  // availability_calendar_state.
  $availability_states_codes = array(
    // N = non communiqué, assimilé à 1 = « Not communicated ».
    'N' => 1,
    // D = disponible, correspond à 2 = « Available ».
    'D' => 2,
    // C = complet, correspond à 3 = « Fully booked ».
    'C' => 3,
  );

  // On assimile l'état F = fermé à C = complet.
  $row->PLANNINGETATS = str_replace('F', 'C', $row->PLANNINGETATS);

  // TODO : généraliser à plusieurs années quand on aura l'information.
  list($year, $states) = explode(':', $row->PLANNINGETATS);
  // Récupérer les intervalles de disponibilités au format Tourinsoft, par
  // exemple FFF, D et F pour une chaîne de départ telle que NNNFFFDNFN.
  $states_matches = array();
  preg_match_all('/([^N])\1*/', $states, $states_matches, PREG_OFFSET_CAPTURE);
  // Convertir les intervalles dans un format compréhensible pour le type de
  // champ availability_calendar. Ce dernier prend en fait en entrée les
  // changements, exprimés sous cette forme :
  // 2,2016-01-21,2016-01-23
  // 4,2016-02-12,2016-02-27
  // 3,2016-03-10,2016-04-24
  // 1,2016-06-09,2016-06-11.
  $row->PLANNINGETATS = '';
  foreach ($states_matches[0] as $state_match) {
    $state = $state_match[0][0];
    $day1 = $state_match[1] + 1;
    $day2 = $day1 + strlen($state_match[0]) - 1;
    if (!isset($availability_states_codes[$state])) {
      // Code de disponibilité invalide.
      continue;
    }
    $row->PLANNINGETATS .= sprintf("%d,%s,%s\n",
      $availability_states_codes[$state],
      date('Y-m-d', mktime(0, 0, 0, 1, $day1, (int) $year)),
      date('Y-m-d', mktime(0, 0, 0, 1, $day2, (int) $year))
    );
  }
}

Il ne vous reste plus qu'à croiser les doigts, et lancer une première migration pour voir à quel endroit votre code va casser en premier !