Subversion Repositories eFlore/Applications.del

Rev

Rev 1499 | Rev 1666 | 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(!isset($o['auteur.id']) || !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 ? */ );
            /* Cependant les champs spécifiques ont priorité sur le masque général.
               Pour cette raison nous supprimons la génération de SQL du masque général sur les
               champ spécifiques qui feront l'objet d'un traitement avec une valeur propre. */
            if(isset($p['masque.auteur'])) unset($or_params['masque.auteur']);
            if(isset($p['masque.departement'])) unset($or_params['masque.departement']);
            if(isset($p['masque.id_zone_geo'])) unset($or_params['masque.id_zone_geo']);
            if(isset($p['masque.tag'])) unset($or_params['masque.tag']);
            if(isset($p['masque.famille'])) unset($or_params['masque.famille']);
            if(isset($p['masque.date'])) unset($or_params['masque.date']);
            if(isset($p['masque.genre'])) unset($or_params['masque.genre']);


            $or_masque = DelTk::requestFilterParams($or_params, array_keys($or_params), $c);
            if(isset($or_params['masque.tag'])) {
                $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
            // *ou* qui ont la "certitude" à ("aDeterminer" *ou* "douteux")
            $req['where'][] = '(' . implode(' OR ', array(
                'vdi.certitude = "aDeterminer"',
                'vdi.certitude = "douteux"',
                'vdi.mots_cles_texte LIKE "%aDeterminer%"',
                'vdi.nom_sel_nn IS NULL', // TODO: ensure pas d'entrée à 0
            )) . ')';
        }
        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;
    }
}