Subversion Repositories eFlore/Applications.del

Rev

Rev 1490 | Rev 1498 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

<?php
/**
 * Le web service observations récupère toutes les observations et, pour chacune d'elle, les
 * images qui lui sont associées.
 * Basée sur la classe antérieure dans ListeObservations.php de
 * Grégoire Duché et Aurélien Peronnet
 * (formaterVote, formaterDeterminations, chargerNombreCommentaire, chargerVotes, chargerDeterminations)
 *
 * @category    php 5.2
 * @package             del
 * @author              Raphaël Droz <raphael@tela-botanica.org>
 * @copyright   Copyright (c) 2013 Tela Botanica (accueil@tela-botanica.org)
 * @license     http://www.cecill.info/licences/Licence_CeCILL_V2-fr.txt Licence CECILL
 * @license     http://www.gnu.org/licenses/gpl.html Licence GNU-GPL
 * @see http://www.tela-botanica.org/wikini/eflore/wakka.php?wiki=ApiIdentiplante01Observations
 *
 * TODO:
 * PDO::prepare()
 * Sphinx pour auteur, genre, ns, commune, tag et masque-général
 */

require_once(dirname(__FILE__) . '/../DelTk.php');
/*
  restore_error_handler();
  restore_exception_handler();
  error_reporting(E_ALL);
*/

class ListeObservations {

    private $conteneur;
    private $gestionBdd;
    private $bdd;
    private $parametres = array();
    private $ressources = array();

    static $tris_possibles = array('date_observation');
    // paramètres autorisés

    static $sql_fields_liaisons = array(
        'dob' => array('id_observation', 'nom_sel AS `determination.ns`', 'nt AS `determination.nt`',
                       'nom_sel_nn AS `determination.nn`', 'famille AS `determination.famille`',
                       'nom_referentiel AS `determination.referentiel`',
                       'ce_zone_geo AS id_zone_geo', 'zone_geo', 'lieudit',
                       'station', 'milieu', 'date_observation', 'mots_cles_texte', 'date_transmission',
                       'ce_utilisateur AS `auteur.id`', 'prenom_utilisateur AS `auteur.prenom`',
                       'nom_utilisateur AS `auteur.nom`', 'courriel_utilisateur AS observateur',
                       'commentaire'),
        'di' => array('id_image', 'date_prise_de_vue AS `date`', 'hauteur',/* 'largeur','nom_original' // apparemment inutilisés */),
        'du' => array('prenom', 'nom', 'courriel'),
        'dc' => array('commentaire')
    );


    public function __construct(Conteneur $conteneur = null) {
        $this->conteneur = $conteneur == null ? new Conteneur() : $conteneur;
        $this->conteneur->chargerConfiguration('config_departements_bruts.ini');
        $this->conteneur->chargerConfiguration('config_observations.ini');
        $this->conteneur->chargerConfiguration('config_mapping_votes.ini');
        $this->conteneur->chargerConfiguration('config_mapping_commentaires.ini');
        $this->navigation = $conteneur->getNavigation();
        $this->masque = $conteneur->getMasque();
        $this->gestionBdd = $conteneur->getGestionBdd();
        $this->bdd = $this->gestionBdd->getBdd();
    }

    static function reformateObservation($obs, $url_pattern = '') {
        $obs = array_map('array_filter', $obs);
        $obs_merged = array();
        foreach($obs as $o) {
            $id = $o['id_observation'];

            // car auteur.id peut être un email, un hash, ou un annuaire_tela.U_ID
            // mais dans les deux premiers cas SELECT courriel AS observateur fait déjà l'affaire
            if(!is_numeric($o['auteur.id'])) $o['auteur.id'] = "0";
            if(!isset($o['auteur.nom'])) $o['auteur.nom'] = '[inconnu]';

            $image = array_intersect_key($o, array_flip(array('id_image', 'date', 'hauteur' , 'largeur', 'nom_original')));
            $image['binaire.href'] = sprintf($url_pattern, $image['id_image']);
            unset($o['id_image'], $o['date'], $o['hauteur'], $o['largeur'], $o['nom_original']);
            if(!isset($obs_merged['"' . $id . '"'])) $obs_merged['"' . $id . '"'] = $o;
            $obs_merged['"' . $id . '"']['images'][] = $image;
        }
        return $obs_merged;
    }

    /**
     * Méthode principale de la classe.
     * Lance la récupération des images dans la base et les place dans un objet ResultatService
     * pour l'afficher.
     * @param array $ressources les ressources situées après l'url de base (ex : http://url/ressource1/ressource2)
     * @param array $parametres les paramètres situés après le ? dans l'url
     **/        
    public function consulter($ressources, $parametres) {
        // SELECT, à terme, pourrait affecter getInfos(), mais en aucune manière getIdObs()
        $req = array('select' => array(), 'join' => array(), 'where' => array(), 'groupby' => array(), 'having' => array());

        // toujours nécessaire puisque nous tapons sur v_del_image qui INNER JOIN cel_images, or nous voulons certes
        // toutes les images, mais nous voulons $limite observations uniques.
        $req['groupby'][] = 'vdi.id_observation';

        $db = $this->bdd;

        // filtrage de l'INPUT
        $params = DelTk::requestFilterParams($parametres, DelTk::$parametres_autorises, $this->conteneur);

        $params['masque.tag'] = DelTk::buildTagsAST(@$parametres['masque.tag'], 'OR', ',');

        // ... et paramètres par défaut
        $params = array_merge(DelTk::$default_params, $params);

        // création des contraintes (masques)
        DelTk::sqlAddConstraint($params, $db, $req);
        self::sqlAddConstraint($params, $db, $req, $this->conteneur);
        self::sqlAddMasqueConstraint($params, $db, $req, $this->conteneur);

        // 1) grunt-work: *la* requête de récupération des id valides (+ SQL_CALC_FOUND_ROWS)
        $idobs_tab = self::getIdObs($params, $req, $db);
        // idobs est une liste (toujours ordonnée) des id d'observations recherchées
        $idobs = array_values(array_map(create_function('$a', 'return $a["id_observation"];'), $idobs_tab));

        if($idobs) {
            $total = $db->recuperer('SELECT FOUND_ROWS() AS c'); $total = intval($total['c']);

            // 2) récupération des données nécessaires pour ces observations (obs + images)
            // ici les champs récupérés sont issus de self::$sql_fields_liaisons mais sans préfixes
            // car tout provient de v_del_image
            $obs_unfmt = self::getInfos($idobs, $db);

            // 3) suppression, merge des données en tableau assez représentatif du futur JSON en output
            $observations = self::reformateObservation($obs_unfmt, $this->conteneur->getParametre('url_images'));

            // 4) récupération des données nécessaires pour ces observations (commentaires + votes)
            // modifie $observations
            $this->configurer();
            $this->chargerDeterminations($observations);

            // 5) restauration de l'ordre souhaité initialement
            $observations = self::sortArrayByArray($observations, $idobs);
        } else {
            $observations = array();
            $total = 0;
        }

        // 6) JSON output
        $resultat = new ResultatService();
        $resultat->corps = array('entete' => DelTk::makeJSONHeader($total, $params, Config::get('url_service')),
                                 'resultats' => $observations);
                
        return $resultat;
    }

    static function sortArrayByArray($array, $orderArray) {
        $ordered = array();
        foreach($orderArray as $key) {
            if(array_key_exists('"' . $key . '"', $array)) {
                $ordered['"' . $key . '"'] = $array['"' . $key . '"'];
                unset($array['"' . $key . '"']);
            }
        }
        return $ordered + $array;
    }

    // SQL helpers
    /*
     * Retourne une liste ordonnée d'id d'observation correspondant aux critères
     * passés dans p et aux clauses where/join présentes dans le tableau $req
     *
     * @param p: $params (filtrés sauf escape-string)
     * @param req: le tableau représentant les composants de la requete SQL
     * @param db: l'instance de db
     */
    static function getIdObs($p, $req, $db) {
        $req_s = sprintf('SELECT SQL_CALC_FOUND_ROWS id_observation' .
                         ' FROM v_del_image vdi'.
                         ' %s' . // LEFT JOIN if any
                         ' WHERE %s'. // where-clause ou TRUE
                         ' %s'. // group-by
                         ' %s'. // having (si commentaires)
                         ' ORDER BY %s %s %s'.
                         ' LIMIT %d, %d -- %s',
                                                 
                         $req['join'] ? implode(' ', $req['join']) : '',
                         $req['where'] ? implode(' AND ', $req['where']) : 'TRUE',

                         $req['groupby'] ? ('GROUP BY ' . implode(', ', array_unique($req['groupby']))) : '',
                         $req['having'] ? ('HAVING ' . implode(' AND ', $req['having'])) : '',

                         $p['tri'], strtoupper($p['ordre']),
                         // date_transmission peut-être NULL et nous voulons de la consistence
                         // (sauf après r1860 de Cel)
                         $p['tri'] == 'date_transmission' ? ', id_observation' : '',

                         $p['navigation.depart'], $p['navigation.limite'], __FILE__ . ':' . __LINE__);

        $res = $db->recupererTous($req_s);
        $err = mysql_error();
        if(!$res && $err) {
            // http_response_code(400);
            // if(defined('DEBUG') && DEBUG) header("X-Debug: $req_s");
            throw new Exception('not found', 400);
        }
        // ordre préservé, à partir d'ici c'est important.
        return $res;
    }

    /*
      Champs récupérés:
      Pour del_images, la vue retourne déjà ce que nous recherchons de cel_obs et cel_images
      (cel_obs.* et cel_[obs_]images.{id_observation, id_image, date_prise_de_vue AS date, hauteur, largeur})
      Pour del_commentaires: nous voulons *
      Reste ensuite à formatter.
      Note: le préfixe de table utilisé ici (vdi) n'impacte *aucune* autre partie du code car rien
      n'en dépend pour l'heure. (inutilisation de $req['select'])
    */
    static function getInfos($idobs, $db) {
        /*$select_fields = implode(',', array_merge(
          array_map(create_function('$a', 'return "vdi.".$a;'), self::$sql_fields_liaisons['dob']),
          array_map(create_function('$a', 'return "vdi.".$a;'), self::$sql_fields_liaisons['di']),
          array_map(create_function('$a', 'return "du.".$a;'), self::$sql_fields_liaisons['du'])));*/
        $select_fields = array_merge(self::$sql_fields_liaisons['dob'],
                                     self::$sql_fields_liaisons['di']);
        $req_s = sprintf('SELECT %s FROM v_del_image vdi'.
                         // ' LEFT JOIN del_commentaire AS dc ON di.id_observation = dc.ce_observation AND dc.nom_sel IS NOT NULL'.
                         ' WHERE id_observation IN (%s)',
                         implode(',', $select_fields),
                         implode(',', $idobs));
        return $db->recupererTous($req_s);
    }


    /**
     * Complément à DelTk::sqlAddConstraint()
     *
     * @param $p les paramètres (notamment de masque) passés par l'URL et déjà traités/filtrés (sauf quotes)
     * @param $req le tableau, passé par référence représentant les composants de la requête à bâtir
     * @param $c conteneur, utilisé soit pour l'appel récursif à requestFilterParams() en cas de param "masque"
     *                                                          soit pour la définition du type (qui utilise la variable nb_commentaires_discussion)
     */
    static function sqlAddConstraint($p, $db, &$req, Conteneur $c = NULL) {
        if(!empty($p['masque.tag'])) {
            // TODO: remove LOWER() lorsqu'on est sur que les tags sont uniformés en minuscule
            // i_mots_cles_texte provient de la VIEW v_del_image
            if(isset($p['masque.tag']['AND'])) {
                /* Lorsque nous interprêtons la chaîne provenant du masque général (cf: buildTagsAST($p['masque'], 'OR', ' ') dans sqlAddMasqueConstraint()),
                   nous sommes splittés par espace. Cependant, assurons que si une virgule à été saisie, nous n'aurons pas le motif
                   " AND CONCAT(mots_cles_texte, i_mots_cles_texte) REGEXP ',' " dans notre requête.
                   XXX: Au 12/11/2013, une recherche sur tag depuis le masque général implique un OU, donc le problème ne se pose pas ici */
                $subwhere = array();
                foreach($p['masque.tag']['AND'] as $tag) {
                    if(trim($tag) == ',') continue;

                    $subwhere[] = sprintf('LOWER(CONCAT(%s)) REGEXP %s',
                                          DelTk::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
                                          $db->proteger(strtolower($tag)));
                }
                $req['where'][] = '(' . implode(' AND ', $subwhere) . ')';
            }
            else {
                $req['where'][] = sprintf('LOWER(CONCAT(%s)) REGEXP %s',
                                          DelTk::sqlAddIfNullPourConcat(array('vdi.mots_cles_texte', 'vdi.i_mots_cles_texte')),
                                          $db->proteger(strtolower(implode('|', $p['masque.tag']['OR']))));
            }
        }

        if(!empty($p['masque.type'])) {
            self::addTypeConstraints($p['masque.type'], $db, $req, $c);
        }
    }

    /* Le masque fait une recherche générique parmi de nombreux champs ci-dessus.
       Nous initialisons donc ces paramètres (excepté masque biensur), et nous rappelons
       récursivement. À la seule différence que nous n'utiliserons que $or_req['where']
       imploded par des " OR ". */
    static function sqlAddMasqueConstraint($p, $db, &$req, Conteneur $c = NULL) {
        if(!empty($p['masque'])) {
            $or_params = array('masque.auteur' => $p['masque'],
                               'masque.departement' => $p['masque'],
                               'masque.id_zone_geo' => $p['masque'],
                               'masque.tag' => $p['masque'],
                               'masque.ns' => $p['masque'],
                               'masque.famille' => $p['masque'],
                               'masque.date' => $p['masque'],
                               'masque.genre' => $p['masque'],
                               /* milieu: TODO ? */ );
            $or_masque = DelTk::requestFilterParams($or_params, array_keys($or_params), $c);
            $or_masque['masque.tag'] = DelTk::buildTagsAST($p['masque'], 'OR', ' ');
            // $or_req = array('select' => array(), 'join' => array(), 'where' => array(), 'groupby' => array(), 'having' => array());
            $or_req = array('join' => array(), 'where' => array());
            DelTk::sqlAddConstraint($or_masque, $db, $or_req);
            self::sqlAddConstraint($or_masque, $db, $or_req);

            if($or_req['where']) {
                $req['where'][] = '(' . implode(' OR ', $or_req['where']) . ')';
                // utile au cas ou des jointures seraient rajoutées
                $req['join'] = array_unique(array_merge($req['join'], $or_req['join']));
            }
        }
    }


    private function configurer() {
        $this->mappingVotes = $this->conteneur->getParametre('mapping_votes');
        $this->mappingCommentaire = $this->conteneur->getParametre('mapping_commentaire');
    }


    /*
     * @param $req: la représentation de la requête MySQL complète, à amender.
     */
    static function addTypeConstraints($val, $db, &$req, Conteneur $c) {
        if(array_key_exists('adeterminer', $val)) {
            //On récupère toutes les observations qui on le tag "aDeterminer" *ou* qui n'ont pas de nom d'espèce
            $req['where'][] = '(' . implode(' OR ', array(
                'vdi.certitude = "aDeterminer"',
                'vdi.mots_cles_texte LIKE "%aDeterminer%"',
                'vdi.nom_sel_nn IS NULL', // TODO: ensure pas d'entrée à 0
            )) . ')';
        }
        if(array_key_exists('aconfirmer', $val)) {
            //On récupère toutes les observations qui ne sont pas "aDeterminer" *et* qui ont un nom d'espèce
            $req['where'][] = '(' . implode(' AND ', array(
                'vdi.nom_sel IS NOT NULL',
                'vdi.certitude != "aDeterminer"',
                '(vdi.mots_cles_texte IS NULL OR vdi.mots_cles_texte NOT LIKE "%aDeterminer%"',
            )) . ')';
        }
        if(array_key_exists('validees', $val)) {
            //On récupère toutes les observations ayant un commentaire doté de proposition_retenue = 1
            $req['join'][] = 'INNER JOIN del_commentaire AS dc ON vdi.id_observation = dc.ce_observation AND dc.proposition_retenue = 1';
        }

        // solution n°1: impraticable
        if(false && array_key_exists('endiscussion', $val)) {
            //Si on veut les observations en discussion,
            // on va récupérer les ids des observations dont le nombre de commentaire est supérieur à N
            $req['select'][] = 'COUNT(dc.id_commentaire) AS comm_count';
            $req['join'][] = 'INNER JOIN del_commentaire AS dc ON vdi.id_observation = dc.ce_observation';
            $req['groupby'][] = 'vdi.id_observation';
            $req['having'][] = "COUNT(id_commentaire) > " . $c->getParametre('nb_commentaires_discussion');
        }

        if(array_key_exists('endiscussion', $val)) {
            $req['where'][] = '(SELECT COUNT(id_commentaire) FROM del_commentaire AS dc'.
                ' WHERE ce_observation = id_observation) > ' . intval($c->getParametre('nb_commentaires_discussion'));
        }
    }


    /**
     * Récupérer toutes les déterminations et le nombre de commentaire au total
     * @param array $observations la liste des observations à mettre à jour
     * */
    private function chargerDeterminations(&$observations) {
        $idObs = array_values(array_map(create_function('$a', 'return $a["id_observation"];'),
                                        $observations));
        $r = sprintf('SELECT * FROM del_commentaire AS dc WHERE dc.nom_sel IS NOT NULL AND ce_observation IN (%s) -- %s',
                     implode(',',$idObs),
                     __FILE__ . ':' . __LINE__);
        $propositions = $this->bdd->recupererTous($r);
        if(!$propositions) return;
        foreach ($propositions as $proposition) {
            $idObs = $proposition['ce_observation'];
            $idComment = $proposition['id_commentaire'];
            $comment = $this->formaterDetermination($idComment, $proposition);
            if($comment) $observations['"' . $idObs . '"']['commentaires'][$idComment] = $comment;
                                
        }
    }

    private function formaterDetermination($commentId, $proposition) {
        if(!$proposition) return NULL;

        $proposition_formatee = array('nb_commentaires' => '0');
        foreach ($this->mappingCommentaire as $nomOriginal => $nomFinal) {
            if (isset($proposition[$nomOriginal])) {
                $proposition_formatee[$nomFinal] = $proposition[$nomOriginal];
            }
        }

        // Charger les votes sur les déterminations
        $resultatsVotes = $this->bdd->recupererTous(
            sprintf('SELECT * FROM del_commentaire_vote WHERE ce_proposition = %d', $commentId));
                
        foreach ($resultatsVotes as $vote) {
            $proposition_formatee['votes'][$vote['id_vote']] = $this->formaterVote($vote);
        }


        // chargerNombreCommentaire()
        // Charger le nombre de commentaires (sans détermination) associé à l'observation
        $listeCommentaires = $this->bdd->recupererTous(sprintf(
            'SELECT ce_commentaire_parent, ce_proposition, COUNT( id_commentaire ) AS nb '.
            'FROM del_commentaire WHERE ce_proposition = %d GROUP BY ce_proposition -- %s',
            $commentId, __FILE__ . ':' . __LINE__));
        foreach ($listeCommentaires as $ligneProposition) {
            // ce test sert à exclure les proposition de 1er niveau qui sont elles aussi des commentaires
            if($ligneProposition['ce_commentaire_parent']) {
                // TODO/debug: id_commentaire_parent != $commentId ??
                // reprendre la "logique" du code... moins de boucles, moins de requêtes, ...
                if($ligneProposition['ce_commentaire_parent'] != $commentId) {
                    // restore_error_handler();
                    error_log(sprintf("possible error: nb_commentaires = %s: comment = %d, parent = %d, %s",
                                      $ligneProposition['nb'], $commentId, $ligneProposition['ce_commentaire_parent'], __FILE__));
                }
                $proposition_formatee['nb_commentaires'] = $ligneProposition['nb'];
            } else {
                $proposition_formatee['observation']['nb_commentaires'] = $ligneProposition['nb'];
            }
        }

        return $proposition_formatee;
    }

    /**
     *  Formater un vote en fonction du fichier de configuration config_votes.ini
     *  @param $votes array()
     * */
    private function formaterVote($vote) {
        $retour = array();
        foreach ($vote as $param=>$valeur) {
            $retour[$this->mappingVotes[$param]] = $valeur;
        }
        return $retour;
    }
}