Subversion Repositories eFlore/Applications.del

Compare Revisions

Ignore whitespace Rev 1839 → Rev 1840

/trunk/services/modules/0.1/Observations.php
39,6 → 39,7
* @license CECILL v2 <http://www.cecill.info/licences/Licence_CeCILL_V2-en.txt>
* @copyright 1999-2014 Tela Botanica <accueil@tela-botanica.org>
*/
 
class Observations extends RestService {
 
private $parametres = array();
95,21 → 96,11
}
 
private function traiterRessources() {
$this->chargerConfigService();
$this->analyserRessources();
$retour = $this->initialiserService();
return $retour;
}
 
private function chargerConfigService() {
$chemin = Config::get('chemin_configurations')."config_{$this->serviceNom}.ini";
Config::charger($chemin);
}
 
/*------------------------------------------------------------------------------------------------------------------
CONFIGURATION DU SERVICE
------------------------------------------------------------------------------------------------------------------*/
 
private function analyserRessources() {
if ($this->methode == 'consulter') {
$this->analyserRessoucesConsultation();
130,7 → 121,7
} else if (count($this->ressources) == 1) {
if ($this->etreRessourceIdentifiant(0)) {
// http://localhost/service:del:0.1/observations/#idObs
$this->sousServiceNom = 'observation';
$this->sousServiceNom = 'observation-details';
}
} else if (count($this->ressources) == 3) {
if ($this->etreRessourceIdentifiant(0) && $this->etreRessourceIdentifiant(1) && $this->verifierRessourceValeur(2, 'vote')) {
207,7 → 198,6
$service = null;
foreach ($chemins as $chemin) {
if (file_exists($chemin)) {
$this->conteneur->chargerConfiguration('config_'.$this->serviceNom.'.ini');
require_once $chemin;
$service = new $classe($this->conteneur);
if ($this->methode == 'consulter') {
/trunk/services/modules/0.1/images/ListeImages.php
1,634 → 1,174
<?php
// declare(encoding='UTF-8');
/**
* @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=ApiIdentiplante01Images
* @see http://www.tela-botanica.org/wikini/identiplante/wakka.php?wiki=IdentiPlante_PictoFlora_MoteurRecherche
* Listes des images avec leurs infos liées.
*
* Backend pour PictoFlora (del.html#page_recherche_images)
* del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc
* del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&masque=plop
* del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3
* del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3&masque=plop
*
*
* == Notes ==
*
* tri=votes et tri=tags: affectent le choix des images affichées (donc getIdImages())
* Cependant ce total ne nous intéresse même pas (MoyenneVotePresenteur.java s'en occupe).
* Seul tri=date_transmission nous évite l'AVG() + GROUP BY
*
* protocole: il affecte l'affichage des information, mais le JSON contient déjà
* l'intégralité (chercher les données de vote pour 1 ou plusieurs protocoles) est quasi-identique.
* Par contre, le tri par moyenne des votes, sous-entend "pour un protocole donné".
* Dès lors le choix d'un protocole doit avoir été fait afin de régler le JOIN et ainsi l'ORDER BY.
* (cf requestFilterParams())
*
* Histoire: auparavant (pré-r142x) un AVG + GROUP BY étaient utilisés pour générer on-the-fly les valeurs
* utilisées ensuite pour l'ORDER BY. La situation à base de del_image_stat
* est déjà bien meilleure sans être pour autant optimale. cf commentaire de sqlAddConstraint()
*
*
* Tags:
* Le comportement habituel dans le masque *général*: les mots sont séparés par des espaces,
* implod()ed par des AND (tous les mots doivent matcher).
* Et le test effectué doit matcher sur:
* %(les tags d'observations)% *OU* %(les tags d'images)% *OU* %(les tags publics)%
*
* Le comportement habituel dans le masque *tag*: les mots ne sont *pas* splittés (1 seule expression),
* Et le test effectué doit matcher sur:
* ^(expression)% *OU* %(expression)% [cf getConditionsImages()]
*
* Par défaut les tags sont comma-separated (OU logique).
* Cependant pour conserver le comportement du masque général qui sous-entend un ET logique sur
* des tags séparés par des espaces recherche
*
* TODO:
* -affiner la gestion de passage de mots-clefs dans le masque général.
* - subqueries dans le FROM pour les critère WHERE portant directement sur v_del_image
* plutôt que dans WHERE (qui nécessite dès lors un FULL-JOIN)
* (http://www.mysqlperformanceblog.com/2007/04/06/using-delayed-join-to-optimize-count-and-limit-queries/)
* - éviter de dépendre d'une jointure systématique sur `cel_obs`, uniquement pour `(date_)transmission
* (cf VIEW del_image)
* - poursuivre la réorganisation des méthodes statiques parmis Observation, ListeObservations et ListeImages2
* - *peut-être*: passer requestFilterParams() en méthode de classe
*
*
* MySQL sux:
* EXPLAIN SELECT id_image FROM v_del_image vdi WHERE vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1 LIMIT 1);
* MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery
* EXPLAIN SELECT * FROM del_image WHERE id_image IN (SELECT 3);
* PRIMARY
* EXPLAIN SELECT * FROM del_image WHERE id_image IN (SELECT MIN(3));
* DEPENDENT SUBQUERY ... ... ... mwarf !
* EXPLAIN SELECT id_image FROM v_del_image vdi WHERE vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1);
* 5.5: MATERIALIZED del_image_tag ALL ce_image NULL NULL NULL 38276 Using where
* 5.1: DEPENDENT SUBQUERY del_image_tag index_subquery ce_image ce_image 8 func 1 Using where
* FORCE INDEX/IGNORE INDEX semble incapable de résoudre le problème de l'optimiseur MySQL
*
* @category DEL
* @package Services
* @subpackage Images
* @version 0.1
* @author Mathias CHOUET <mathias@tela-botanica.org>
* @author Jean-Pascal MILCENT <jpm@tela-botanica.org>
* @author Aurelien PERONNET <aurelien@tela-botanica.org>
* @license GPL v3 <http://www.gnu.org/licenses/gpl.txt>
* @license CECILL v2 <http://www.cecill.info/licences/Licence_CeCILL_V2-en.txt>
* @copyright 1999-2014 Tela Botanica <accueil@tela-botanica.org>
*/
 
require_once(dirname(__FILE__) . '/../DelTk.php');
require_once(dirname(__FILE__) . '/../observations/Observation.php');
restore_error_handler();
restore_exception_handler();
error_reporting(E_ALL);
 
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&masque=plop
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3&masque=plop
 
//restore_error_handler();
//restore_exception_handler();
//error_reporting(E_ALL);
class ListeImages {
 
// TODO: PHP-x.y, ces variables devrait être des "const"
static $format_image_possible = array('O','CRX2S','CRS','CXS','CS','XS','S','M','L','XL','X2L','X3L');
private $ressources = array();
private $parametres = array();
private $conteneur;
private $bdd;
private $filtrage;
private $sql;
private $navigation;
private $paramsFiltres = array();
private $mappings = array();
 
static $tri_possible = array('date_transmission', 'date_observation', 'votes', 'tags', 'points');
 
// en plus de ceux dans DelTk
static $parametres_autorises = array('protocole', 'masque.tag_cel', 'masque.tag_pictoflora', 'masque.milieu');
 
static $default_params = array('navigation.depart' => 0, 'navigation.limite' => 10,
'tri' => 'date_transmission', 'ordre' => 'desc',
// spécifiques à PictoFlora:
'format' => 'XL');
 
static $default_proto = 3; // proto par défaut: capitalisation d'img (utilisé uniquement pour tri=(tags|votes|points))
 
static $mappings = array(
'observations' => array( // v_del_image
"id_observation" => 1,
"date_observation" => 1,
"date_transmission" => 1,
"famille" => "determination.famille",
"nom_sel" => "determination.ns",
"nom_sel_nn" => "determination.nn",
"nom_referentiel" => "determination.referentiel",
"nt" => "determination.nt",
"ce_zone_geo" => "id_zone_geo",
"zone_geo" => 1,
"lieudit" => 1,
"station" => 1,
"milieu" => 1,
"mots_cles_texte" => "mots_cles_texte",
"commentaire" => 1,
"ce_utilisateur" => "auteur.id",
"nom_utilisateur" => "auteur.nom",
"prenom_utilisateur" => "auteur.prenom",
"courriel_utilisateur" => "auteur.courriel",),
'images' => array( // v_del_image
'id_image' => 1,
// l'alias suivant est particulier: in-fine il doit s'appeler mots_cles_texte
// mais nous afin d'éviter un conflit d'alias nous le renommons plus tard (reformateImagesDoubleIndex)
'i_mots_cles_texte' => 1)
);
 
public function __construct(Conteneur $conteneur) {
$this->conteneur = $conteneur;
$this->bdd = $this->conteneur->getBdd();
$this->filtrage = $this->conteneur->getParametresFiltrage();
$this->sql = $this->conteneur->getSql();
$this->navigation = $this->conteneur->getNavigation();
 
$this->mappings['observations'] = $this->conteneur->getParametreTableau('observations.mapping');
$this->mappings['images'] = $this->conteneur->getParametreTableau('images.mapping');
}
 
public function consulter($ressources, $parametres) {
/* Certes nous sélectionnons ici (nom|prenom|courriel)_utilisateur de cel_obs, mais il ne nous intéressent pas
Par contre, ci-dessous nous prenons i_(nom|prenom|courriel)_utilisateur.
Notons cependant qu'aucun moyen ne devrait permettre que i_*_utilisateur != *_utilisateur
Le propriétaire d'une obs et de l'image associée est *toujours* le même. */
array_walk(self::$mappings['observations'], create_function('&$val, $k', 'if($val==1) $val = $k;'));
array_walk(self::$mappings['images'], create_function('&$val, $k', 'if($val==1) $val = $k;'));
// pour les votes, les mappings de "Observation" nous suffisent
array_walk(Observation::$mappings['votes'], create_function('&$val, $k', 'if($val==1) $val = $k;'));
$this->ressources = $ressources;
$this->parametres = $parametres;
 
// la nécessité du 'groupby' dépend des 'join's utilisés (LEFT ou INNER) ainsi que de la cardinalité
// de `ce_image` dans ces tables jointes.
// Contrairement à IdentiPlantes, nous n'avons de HAVING pour PictoFlora, mais par contre un ORDER BY
$req = array('select' => array(), 'join' => array(), 'where' => array(), 'groupby' => array(), 'orderby' => array());
$this->paramsFiltres = $this->filtrage->filtrerUrlParamsAppliImg();
$this->sql->setParametres($this->paramsFiltres);
$this->sql->ajouterContraintes();
$this->sql->ajouterConstrainteAppliImg();
$this->sql->definirOrdreSqlAppliImg();
 
$db = $this->bdd;
$idImgs = $this->getIdImages();
$this->navigation->setTotal($this->getTotal());
 
// filtrage de l'INPUT général, on réutilise 90% de identiplante en terme de paramètres autorisés
// ($parametres_autorises) sauf... masque.type qui fait des modif' de WHERE sur les mots-clefs.
// Évitons ce genre de chose pour PictoFlora et les risques de conflits avec masque.tag
// même si ceux-ci sont improbables (pas d'<input> pour cela).
$params_ip = DelTk::requestFilterParams($parametres,
array_diff(DelTk::$parametres_autorises, array('masque.type')),
$this->conteneur);
// Ce n'est pas la peine de continuer s'il n'y a pas eu de résultats
$resultat = new ResultatService();
$resultat->corps = array('entete' => $this->navigation->getEntete(), 'resultats' => array());
if (count($idImgs) > 0) {
$liaisons = $this->getInfosImages($idImgs);
list($images, $images_indexe_par_id_image) = $this->reformaterImagesDoubleIndex($liaisons);
 
// notre propre filtrage sur l'INPUT
$params_pf = self::requestFilterParams($parametres,
array_merge(DelTk::$parametres_autorises, self::$parametres_autorises));
// Chargement des votes pour ces images et pour *tous* les protocoles
$votes = $this->sql->getVotesDesImages($idImgs);
if ($votes) {
// ATTENTION : $images_indexe_par_id_image est lié par référence à $images !
$this->sql->ajouterInfosVotesProtocoles($votes, $images_indexe_par_id_image);
}
 
/* filtrage des tags + sémantique des valeurs multiples:
Lorsqu'on utilise masque.tag* pour chercher des tags, ils sont
postulés comme séparés par des virgule, et l'un au moins des tags doit matcher. */
$params_pf['masque.tag_cel'] = DelTk::buildTagsAST(@$parametres['masque.tag_cel'], 'OR', ',');
 
if(!isset($parametres['masque.tag_pictoflora']) && isset($parametres['masque.tag'])) {
$parametres['masque.tag_pictoflora'] = $parametres['masque.tag'];
$resultat->corps = array(
'entete' => $this->navigation->getEntete(),
'resultats' => $images);
}
$params_pf['masque.tag_pictoflora'] = DelTk::buildTagsAST(@$parametres['masque.tag_pictoflora'], 'OR', ',');
 
$params = array_merge(
DelTk::$default_params, // paramètre par défaut Identiplante
self::$default_params, // paramètres par défaut PictoFlora
$params_ip, // les paramètres passés, traités par Identiplante
$params_pf); // les paramètres passés, traités par PictoFlora
 
if (isset($parametres['format'])) {
$params['format'] = $parametres['format'];
}
 
// création des contraintes (génériques de DelTk)
DelTk::sqlAddConstraint($params, $db, $req);
// création des contraintes spécifiques (sur les tags essentiellement)
self::sqlAddConstraint($params, $db, $req, $this->conteneur);
// création des contraintes spécifiques impliquées par le masque général
self::sqlAddMasqueConstraint($params, $db, $req, $this->conteneur);
// l'ORDER BY s'avére complexe
self::sqlOrderBy($params, $db, $req);
 
// 1) grunt-work: *la* requête de récupération des id valides (+ SQL_CALC_FOUND_ROWS)
// $idobs_tab = ListeObservations::getIdObs($params, $req, $db);
$idobs_tab = self::getIdImages($params, $req, $db);
 
// Ce n'est pas la peine de continuer s'il n'y a pas eu de résultats dans la table del_obs_images
if (!$idobs_tab) {
$resultat = new ResultatService();
$resultat->corps = array('entete' => DelTk::makeJSONHeader(0, $params, Config::get('url_service')),
'resultats' => array());
return $resultat;
}
 
// idobs est une liste (toujours ordonnée) des id d'observations recherchées
$idobs = array_values(array_map(create_function('$a', 'return $a["id_image"];'), $idobs_tab));
$total = $db->recuperer('SELECT FOUND_ROWS() AS c'); $total = intval($total['c']);
 
$liaisons = self::chargerImages($db, $idobs);
 
list($images, $images_keyed_by_id_image) = self::reformateImagesDoubleIndex(
$liaisons,
$this->conteneur->getParametre('images.url_images'),
$params['format']);
 
// on charge les votes pour ces images et pour *tous* les protocoles
$votes = Observation::chargerVotesImage($db, $liaisons, NULL);
 
// subtilité, nous passons ici le tableau d'images indexé par id_image qui est bien plus pratique pour
// associer les vote à un tableau, puisque nous ne connaissons pas les id d'observation.
// Mais magiquement (par référence), cela va remplir notre tableau indexé par couple d'id (id_image, id_observation)
// cf reformateImagesDoubleIndex() à qui revient la tâche de créer ces deux versions simultanément lorsque
// c'est encore possible.
if ($votes) {
Observation::mapVotesToImages($votes, $images_keyed_by_id_image);
}
 
// les deux masques de tags sont transformés en AST dans le processus de construction de la requête.
// Reprenous les paramètres originaux non-nettoyés (ils sont valables car le nettoyage est déterministe)
$params_header = array_merge($params, array_filter(array('masque.tag_cel' => @$parametres['masque.tag_cel'],
'masque.tag_pictoflora' => @$parametres['masque.tag_pictoflora'])));
$resultat = new ResultatService();
$resultat->corps = array(
'entete' => DelTk::makeJSONHeader($total, $params_header, Config::get('url_service')),
'resultats' => $images);
return $resultat;
}
 
private function getIdImages() {
$requete = 'SELECT SQL_CALC_FOUND_ROWS id_image '.
'FROM v_del_image AS vdi '.
$this->sql->getJoin().
'WHERE '.$this->sql->getWhere().
$this->sql->getGroupBy().
$this->sql->getOrderBy().
$this->sql->getLimit().
' -- '.__FILE__.':'.__LINE__;
 
/**
* Supprime une image directement dans le CEL en faisant un appel à un web service du CEL.
* Utilisé uniquement par les admins.
*
* @param array $ressources tableau des informations contenues dans l'url après le nom du service
* @param array $parametres contenu du post
* @return mixed Chaine "OK" (en majuscule) en cas de succès, booléen "false" en cas d'échec
*/
public function supprimer($ressources) {
$controlAcces = $this->conteneur->getControleAcces();
$controlAcces->etreUtilisateurAvecDroitAdmin();
 
$urlServiceBase = $this->conteneur->getParametre('urlServiceCelImage');
$idImage = $ressources[0];
$url = $urlServiceBase.$idImage;
 
$clientHttp = $this->conteneur->getRestClient();
$retourCel = $clientHttp->supprimer($url);
$retour = preg_match('/^OK$/i', $retourCel) ? 'OK' : false;
return $retour;
}
 
/**
* TODO: partie spécifique liées à la complexité de PictoFlora:
* génération de la clause ORDER BY (génère la valeur de la clef orderby' de $req)
* nécessaire ? tableau sprintf(key (tri) => value (ordre), key => value ...).
* Cependant il est impensable de joindre sur un AVG() des valeurs des votes pour
* *chaque* couple (id_image, protocole) de la base afin de trouver les images
* les "mieux notées", ou bien les images ayant le "plus de tags" (COUNT())
*/
static function sqlOrderBy($p, $db, &$req) {
// parmi self::$tri_possible
if ($p['tri'] == 'votes') { // LEFT JOIN sur "dis" ci-dessous
$req['orderby'] = 'dis.moyenne ' . $p['ordre'] . ', dis.nb_votes ' . $p['ordre'];
return;
}
 
if ($p['tri'] == 'points') { // LEFT JOIN sur "dis" ci-dessous
$req['orderby'] = 'dis.nb_points ' . $p['ordre'] . ', dis.moyenne ' . $p['ordre'] . ', dis.nb_votes ' . $p['ordre'];
return;
}
 
if ($p['tri'] == 'tags') { // LEFT JOIN sur "dis" ci-dessous
$req['orderby'] = 'dis.nb_tags ' . $p['ordre'];
return;
}
 
if ($p['tri'] == 'date_observation') {
$req['orderby'] = 'date_observation ' . $p['ordre'] . ', id_observation ' . $p['ordre'];
return;
}
 
// tri == 'date_transmission'
// avant cel:r1860, date_transmission pouvait être NULL
// or nous voulons de la cohérence (notamment pour phpunit)
$req['orderby'] = 'date_transmission ' . $p['ordre'] . ', id_observation ' . $p['ordre'];
}
 
/*
* in $p: un tableau de paramètres, dont:
* - 'masque.tag_cel': *tableau* de mots-clefs à chercher parmi cel_image.mots_clefs_texte
* - 'masque.tag_pictoflora': *tableau* de mots-clefs à chercher parmi del_image_tag.tag_normalise
* - 'tag_explode_semantic': défini si les éléments sont tous recherchés ou NON
*
* in/ou: $req: un tableau de structure de requête MySQL
*
* Attention, le fait que nous cherchions masque.tag_cel OU/ET masque.tag_cel
* ne dépend pas de nous, mais du niveau supérieur de construction de la requête:
* Soit directement $this->consulter() si des masque.tag* sont passés
* (split sur ",", "AND" entre chaque condition, "OR" pour chaque valeur de tag)
* Soit via sqlAddMasqueConstraint():
* (pas de split, "OR" entre chaque condition) [ comportement historique ]
* équivalent à:
* (split sur " ", "OR" entre chaque condition, "AND" pour chaque valeur de tag)
*
*/
static function sqlAddConstraint($p, $db, &$req, Conteneur $c = NULL) {
// TODO implement dans DelTk ?
if (!empty($p['masque.milieu'])) {
$req['where'][] = 'vdi.milieu LIKE '.$db->proteger('%' . $p['masque.milieu'].'%');
}
 
/* Pour le tri par AVG() des votes nous avons toujours un protocole donné,
celui-ci indique sur quels votes porte l'AVG.
(c'est un *vote* qui porte sur un protocole et non l'image elle-même) */
/* TODO: perf problème:
1) SQL_CALC_FOUND_ROWS: fixable en:
- dissociant le comptage de la récup d'id + javascript async
- ou ne rafraîchir le total *que* pour les requête impliquant un changement de pagination
(paramètre booléen "with-total" par exemple)
2) jointure forcées: en utilisant `del_imagè`, nous forçons les 2 premiers
JOIN sur cel_obs_images et cel_obs pour filtrer sur "transmission".
Dénormaliser cette valeur et l'intégrer à `cel_images` ferait économiser cette couteuse
jointure, ... lorsqu'aucun masque portant sur `cel_obs` n'est utilisé
3) non-problème: l'ordre des joins est forcé par l'usage de la vue:
(cel_images/cel_obs_images/cel_obs/del_image_stat)
Cependant c'est à l'optimiseur de définir son ordre préféré. */
if ($p['tri'] == 'votes' || $p['tri'] == 'points') {
// $p['protocole'] *est* défini (cf requestFilterParams())
$req['join']['dis'] = sprintf('LEFT JOIN del_image_stat dis'.
' ON vdi.id_image = dis.ce_image'.
' AND dis.ce_protocole = %d',
$p['protocole']);
}
 
if ($p['tri'] == 'tags') {
$req['join'][] = sprintf('%s JOIN del_image_stat dis ON vdi.id_image = dis.ce_image',
($p['ordre'] == 'desc') ? 'INNER' : 'LEFT');
// nécessaire (dup ce_image dans del_image_stat)
$req['groupby'][] = 'vdi.id_observation';
}
 
// car il ne sont pas traités par la générique requestFilterParams() les clefs "masque.tag_*"
// sont toujours présentes; bien que parfois NULL.
if ($p['masque.tag_cel']) {
if (isset($p['masque.tag_cel']['AND'])) {
// TODO: utiliser les tables de mots clefs normaliées dans tb_cel ?
// et auquel cas laisser au client le choix du couteux "%" ?
$tags = $p['masque.tag_cel']['AND'];
array_walk($tags, create_function('&$val, $k, $db',
'$val = sprintf("CONCAT(IFNULL(vdi.mots_cles_texte,\'\'),IFNULL(vdi.i_mots_cles_texte,\'\')) LIKE %s",
$db->proteger("%".$val."%"));'),
$db);
$req['where'][] = '(' . implode(' AND ', $tags) . ')';
} else {
$req['where'][] = sprintf("CONCAT(IFNULL(vdi.mots_cles_texte,''),IFNULL(vdi.i_mots_cles_texte,'')) REGEXP %s",
$db->proteger(implode('|', $p['masque.tag_cel']['OR'])));
$resultats = $this->bdd->recupererTous($requete);
$idImgs = array();
if ($resultats !== false ) {
foreach ($resultats as $resultat) {
$idImgs[] = $resultat['id_image'];
}
}
 
if ($p['masque.tag_pictoflora']) {
// inutilisable pour l'instant
// self::sqlAddPictoFloraTagConstraint1($p, $db, $req);
 
// intéressante, mais problème d'optimiseur MySQL 5.5 (dependant subquery)
// self::sqlAddPictoFloraTagConstraint2($p, $db, $req);
 
// approche fiable mais sous-optimale
self::sqlAddPictoFloraTagConstraint3($p, $db, $req);
}
return $idImgs;
}
 
/* approche intéressante si les deux problèmes suivants peuvent être résolu:
- LEFT JOIN => dup => *gestion de multiples GROUP BY* (car in-fine un LIMIT est utilisé)
- dans le cas d'un ET logique, comment chercher les observations correspondantes ? */
static function sqlAddPictoFloraTagConstraint1($p, $db, &$req) {
// XXX: utiliser tag plutôt que tag_normalise ?
$req['join'][] = 'LEFT JOIN del_image_tag dit ON dit.ce_image = vdi.id_image';
$req['where'][] = 'dit.actif = 1';
$req['groupby'][] = 'vdi.id_image'; // TODO: nécessaire (car dup') mais risque de conflict en cas de tri (multiple GROUP BY)
// XXX: en cas de ET, possibilité du GROUP_CONCAT(), mais probablement sans grand intérêt, d'où une boucle
if (isset($p['masque.tag_pictoflora']['AND'])) {
// TODO/XXX : comment matcher les observations ayant tous les mots-clef passés ?
// ... le LEFT-JOIN n'y semble pas adapté
} else {
$protected_tags = array();
foreach ($p['masque.tag_pictoflora']['OR'] as $tag) {
$protected_tags[] = $db->proteger(strtolower($tag));
}
$req['where'][] = sprintf('tag_normalise IN (%s)', implode(',', $protected_tags));
}
private function getTotal() {
$resultat = $this->bdd->recuperer('SELECT FOUND_ROWS() AS nbre');
return intval($resultat['nbre']);
}
 
// inutilisé pour l'instant pour cause de soucis d'optimiseur MySQL (cf commentaire en intro)
static function sqlAddPictoFloraTagConstraint2($p, $db, &$req) {
// Note à propos des 4 "@ instruction" ci-dessous (notamment sur recupererTous())
// REGEXP permet un puissant mécanisme de sélection des obs/image à qui sait
// l'utiliser, mais peut sortir une erreur en cas de REGEXP invalide
// ex: REGEX "^(".
// Pour l'heure nous ignorons ce type d'erreur car aucun de nos champ de recherche
// ne peuvent (ou ne devrait) comporter des meta-caractères
// ([])?*+\\
if (isset($p['masque.tag_pictoflora']['AND'])) {
// optimsation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
// donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
sort($p['masque.tag_pictoflora']['AND']);
$req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP %s)",
$db->proteger(implode('.*', $p['masque.tag_pictoflora']['AND'])));
} else {
$req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise) REGEXP %s)",
$db->proteger(implode('|', $p['masque.tag_pictoflora']['OR'])));
}
}
private function getInfosImages($idImgs) {
$obsChamps = $this->sql->getAliasDesChamps($this->mappings['observations']);
$imgChamps = $this->sql->getAliasDesChamps($this->mappings['images']);
$idImgsConcat = implode(',', $idImgs);
 
// si l'on est bassiné par les "DEPENDENT SUBQUERY", nous la faisons donc indépendemment via cette fonction
static function sqlAddPictoFloraTagConstraint3($p, $db, &$req) {
if (isset($p['masque.tag_pictoflora']['AND'])) {
// optimsation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
// donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
sort($p['masque.tag_pictoflora']['AND']);
$requete = "SELECT CONCAT(id_image, '-', id_observation) AS jsonindex, $obsChamps, $imgChamps ".
'FROM v_del_image '.
"WHERE id_image IN ($idImgsConcat) ".
"ORDER BY FIELD(id_image, $idImgsConcat) ". // important car MySQL ne conserve par l'ordre du IN()
'-- '.__FILE__.':'.__LINE__;
 
// plutôt que db->connexion->query->fetchColumn(), une API pourrie nous oblige à ...
$ids = @$db->recupererTous(sprintf(
"SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP %s",
$db->proteger(implode('.*', $p['masque.tag_pictoflora']['AND']))));
 
// puis:
$ids = @array_map(create_function('$e', 'return $e["ce_image"];'), $ids);
$ids = !empty($ids) ? implode(',', $ids) : 'SELECT ce_image FROM del_image_tag WHERE false';
$req['where'][] = sprintf("vdi.id_image IN (%s)", $ids);
} else {
$ids = @$db->recupererTous(sprintf(
"SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise) REGEXP %s",
$db->proteger(implode('|', $p['masque.tag_pictoflora']['OR']))));
 
$ids = @array_map(create_function('$e', 'return $e["ce_image"];'), $ids);
$ids = !empty($ids) ? implode(',', $ids) : 'SELECT ce_image FROM del_image_tag WHERE false';
$req['where'][] = sprintf("vdi.id_image IN (%s)", $ids);
}
return $this->bdd->recupererTous($requete);
}
 
static function getIdImages($p, $req, $db) {
$req = sprintf(
'SELECT SQL_CALC_FOUND_ROWS id_image' .
//', dis.moyenne, dis.nb_points, dis.nb_votes' . // debug
' FROM v_del_image vdi'.
' %s' . // LEFT JOIN if any
' WHERE %s'. // where-clause ou TRUE
' %s'. // group-by
' ORDER BY %s'.
' LIMIT %d, %d -- %s',
 
$req['join'] ? implode(' ', array_unique($req['join'])) : '',
$req['where'] ? implode(' AND ', $req['where']) : 'TRUE',
 
$req['groupby'] ? ('GROUP BY ' . implode(', ', array_unique($req['groupby']))) : '',
 
$req['orderby'],
 
$p['navigation.depart'], $p['navigation.limite'], __FILE__ . ':' . __LINE__);
return $db->recupererTous($req);
}
 
static function chargerImages($db, $idImg) {
$obs_fields = DelTk::sqlFieldsToAlias(self::$mappings['observations'], NULL);
$image_fields = DelTk::sqlFieldsToAlias(self::$mappings['images'], NULL);
 
return $db->recupererTous(sprintf('SELECT '.
' CONCAT(id_image, "-", id_observation) AS jsonindex,'.
' %1$s, %2$s FROM v_del_image '.
' WHERE %3$s'.
' ORDER BY %4$s'. // important car MySQL ne conserve par l'ordre du IN()
' -- %5$s',
$obs_fields, $image_fields,
sprintf('id_image IN (%s)', implode(',', $idImg)),
sprintf('FIELD(id_image, %s)', implode(',', $idImg)),
__FILE__ . ':' . __LINE__));
}
 
/* "masque" ne fait jamais que faire une requête sur la plupart des champs, (presque) tous traités
de manière identique à la seule différence que:
1) ils sont combinés par des "OU" logiques plutôt que des "ET".
2) les tags sont traités différemment pour conserver la compatibilité avec l'utilisation historique:
Tous les mots-clefs doivent matcher et sont séparés par des espaces
(dit autrement, str_replace(" ", ".*", $mots-clefs-requête) =~ $mots-clefs-mysql)
Pour plus d'information: (ListeObservations|DelTk)::sqlAddMasqueConstraint() */
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.commune' => $p['masque'], // TODO/XXX ?
'masque.id_zone_geo' => $p['masque'],
 
/* tous-deux remplacent masque.tag
mais sont traité séparément des requestFilterParams() */
// 'masque.tag_cel' => $p['masque'],
// 'masque.tag_pictoflora' => $p['masque'],
 
'masque.ns' => $p['masque'],
'masque.famille' => $p['masque'],
'masque.date' => $p['masque'],
'masque.genre' => $p['masque'],
'masque.milieu' => $p['masque'],
'masque.tag_cel' => $p['masque'],
'masque.tag_pictoflora' => $p['masque'],
 
// tri est aussi nécessaire car affecte les contraintes de JOIN
'tri' => $p['tri'],
'ordre' => $p['ordre']);
if (array_key_exists('protocole', $p)) {
$or_params['protocole'] = $p['protocole'];
}
 
/* 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.commune'])) unset($or_params['masque.commune']);
if(isset($p['masque.id_zone_geo'])) unset($or_params['masque.id_zone_geo']);
if(isset($p['masque.ns'])) unset($or_params['masque.ns']);
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']);
if(isset($p['masque.milieu'])) unset($or_params['masque.milieu']);
if(isset($p['masque.tag_cel'])) unset($or_params['masque.tag_cel']);
if(isset($p['masque.tag_pictoflora'])) unset($or_params['masque.tag_pictoflora']);
 
$or_masque = array_merge(
DelTk::requestFilterParams($or_params, NULL, $c /* pour masque.departement */),
self::requestFilterParams($or_params)
);
 
/* Lorsqu'on utilise le masque général pour chercher des tags, ils sont
postulés comme séparés par des espaces, et doivent être tous matchés. */
if (isset($or_params['masque.tag_cel'])) {
$or_masque['masque.tag_cel'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
}
if (isset($or_params['masque.tag_pictoflora'])) {
$or_masque['masque.tag_pictoflora'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
}
 
// pas de select, groupby & co ici: uniquement 'join' et 'where'
$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']));
}
}
}
 
 
// cf Observation::reformateObservationSimpleIndex() et ListeObservations::reformateObservation()
// (trop de variétés de formatage, à unifier côté client pour unifier côté backend ...)
static function reformateImagesDoubleIndex($obs, $url_pattern = '', $image_format = 'XL') {
// XXX: cf Observation.php::consulter(), nous pourriouns ici
// conserver les valeurs vides (pour les phptests notamment, ou non)
// $obs = array_map('array_filter', $obs);
private function reformaterImagesDoubleIndex($imagesInfos) {
$urlImgTpl = $this->conteneur->getParametre('cel_img_url_tpl');
$imageFormat = isset($this->paramsFiltres['format']) ? $this->paramsFiltres['format'] : 'XL';
$obs_merged = $obs_keyed_by_id_image = array();
foreach ($obs as $o) {
foreach ($imagesInfos as $infos) {
// ceci nous complique la tâche pour le reste du processing...
$id = $o['jsonindex'];
$id = $infos['jsonindex'];
// ainsi nous utilisons deux tableaux: le final, indexé par couple d'id(image-obs)
// et celui indexé par simple id_image qui est fort utile pour mapVotesToImages()
// mais tout deux partage leur référence à "protocole"
$image = array(
'id_image' => $o['id_image'],
'binaire.href' => sprintf($url_pattern, $o['id_image'], $image_format),
'mots_cles_texte' => @$o['i_mots_cles_texte'], // @, peut avoir été filtré par array_map() ci-dessus
'id_image' => $infos['id_image'],
'binaire.href' => sprintf($urlImgTpl, $infos['id_image'], $imageFormat),
'mots_cles_texte' => @$infos['mots_cles_texte_img'], // @, peut avoir été filtré par array_map() ci-dessus
);
 
unset($o['id_image'], $o['i_mots_cles_texte'], $o['jsonindex']);
unset($infos['id_image'], $infos['mots_cles_texte_img'], $infos['jsonindex']);
if (!isset($obs_merged[$id])) {
$obs_merged[$id] = $image;
}
$obs_merged[$id]['observation'] = $o;
$obs_merged[$id]['observation'] = $infos;
$obs_merged[$id]['protocoles_votes'] = array();
 
$obs_keyed_by_id_image[$image['id_image']]['protocoles_votes'] =& $obs_merged[$id]['protocoles_votes'];
}
 
return array($obs_merged,$obs_keyed_by_id_image);
return array($obs_merged, $obs_keyed_by_id_image);
}
 
// complete & override DelTk::requestFilterParams() (même usage)
static function requestFilterParams(Array $params, $parametres_autorises = NULL) {
if ($parametres_autorises) { // filtrage de toute clef inconnue
$params = array_intersect_key($params, array_flip($parametres_autorises));
}
/**
* Supprime une image directement dans le CEL en faisant un appel à un web service du CEL.
* Utilisé uniquement par les admins.
*
* @param array $ressources tableau des informations contenues dans l'url après le nom du service
* @param array $parametres contenu du post
* @return mixed Chaine "OK" (en majuscule) en cas de succès, booléen "false" en cas d'échec
*/
public function supprimer($ressources) {
$controlAcces = $this->conteneur->getControleAcces();
$controlAcces->etreUtilisateurAvecDroitAdmin();
 
$p = array();
$p['tri'] = DelTk::unsetIfInvalid($params, 'tri', self::$tri_possible);
$p['format'] = DelTk::unsetIfInvalid($params, 'format', self::$format_image_possible);
$urlServiceBase = $this->conteneur->getParametre('urlServiceCelImage');
$idImage = $ressources[0];
$url = $urlServiceBase.$idImage;
 
// "milieu" inutile pour IdentiPlantes ?
if (isset($params['masque.milieu'])) {
$p['masque.milieu'] = trim($params['masque.milieu']);
}
 
// compatibilité
if (isset($params['masque.tag'])) {
$params['masque.tag_cel'] = $params['masque.tag_pictoflora'] = $params['masque.tag'];
}
 
if ($p['tri'] == 'votes' || $p['tri'] == 'tags' || $p['tri'] == 'points') {
// ces critère de tri des image à privilégier ne s'applique qu'à un protocole donné
if(!isset($params['protocole']) || !is_numeric($params['protocole'])) {
$p['protocole'] = self::$default_proto;
} else {
$p['protocole'] = intval($params['protocole']);
}
}
 
return array_filter($p, create_function('$a','return !in_array($a, array("",false,null),true);'));
$clientHttp = $this->conteneur->getRestClient();
$retourCel = $clientHttp->supprimer($url);
$retour = preg_match('/^OK$/i', $retourCel) ? 'OK' : false;
return $retour;
}
 
}
/trunk/services/modules/0.1/observations/Observation.php
File deleted
\ No newline at end of file
/trunk/services/modules/0.1/observations/ObservationDetails.php
New file
0,0 → 1,211
<?php
/**
* Web service retournant toutes les infos d'une observation donnée :
* images, votes sur image et protocole, commentaires, votes sur commentaires, ...
*
* @category DEL
* @package Observations
* @version 0.1
* @author Raphaël Droz <raphael@tela-botanica.org>
* @author Jean-Pascal MILCENT <jpm@tela-botanica.org>
* @copyright Copyright (c) 2013, Tela Botanica (accueil@tela-botanica.org)
* @license GPL v3 <http://www.gnu.org/licenses/gpl.txt>
* @license CECILL v2 <http://www.cecill.info/licences/Licence_CeCILL_V2-en.txt>
* @see http://www.tela-botanica.org/wikini/eflore/wakka.php?wiki=ApiIdentiplante01Observations
*
* @config-depend: "url_image" (dans configurations/config_observations.ini) ex: http://www.tela-botanica.org/appli:cel-img:%09dXL.jpg
*/
 
// http://localhost/del/services/0.1/observations/#id => une observation donnée et ses images, SANS LES propositions & nombre de commentaire
 
require_once(dirname(__FILE__) . '/../DelTk.php');
 
class ObservationDetails {
 
private $conteneur;
private $bdd;
private $sql;
private $idObs;
private $protocole;
private $observation;
private $mappings = array();
public function __construct(Conteneur $conteneur) {
$this->conteneur = $conteneur;
$this->bdd = $this->conteneur->getBdd();
$this->sql = $this->conteneur->getSql();
 
$this->mappings['observations'] = $this->conteneur->getParametreTableau('observations.mapping');
$this->mappings['images'] = $this->conteneur->getParametreTableau('images.mapping');
$this->mappings['votes'] = $this->conteneur->getParametreTableau('votes.mapping');
$this->mappings['commentaires'] = $this->conteneur->getParametreTableau('commentaires.mapping');
// les deux alias suivants sont particuliers afin d'éviter un conflit d'alias lors des jointures avec del_commentaire_vote
$this->mappings['commentaires']['ce_utilisateur'] = '__auteur_com';
$this->mappings['commentaires']['date'] = '__date_com';
}
 
public function consulter($ressources, $parametres) {
$this->idObs = $ressources[0];
$this->protocole = isset($parametres['protocole']) && is_numeric($parametres['protocole']) ? intval($parametres['protocole']) : null;
 
// 1) récupération de l'observation (et de ses images (v_del_image est une vue utilisant des INNER JOIN))
$infos = $this->getInfosObservationEtImages();
if (! $infos) {
$message = "Aucune observation ne possède d'identifiant '{$this->idObs}'.";
throw new Exception($message, RestServeur::HTTP_CODE_RESSOURCE_INTROUVABLE);
}
$this->formaterObservation($infos);
//var_dump($this->observation);
// 3) charge les données de votes et protocoles associés aux images
if ($this->observation['images']) {
$idsImages = array_keys($this->observation['images']);
$votes = $this->sql->getVotesDesImages($idsImages, $this->protocole);
$this->sql->ajouterInfosVotesProtocoles($votes, $this->observation['images']);
}
 
// 4) charge les commentaires et les votes associés -> modifie/créé $observation['commentaires']
$commentaires = $this->getCommentaires();
$this->ajouterCommentaires($commentaires);
 
// désindexe le tableau (tel qu'apparement attendu par les applis), c'est une exception
// TODO : corriger l'appli cliente pour utiliser les index puis supprimer cette ligne
$this->observation['images'] = array_values($this->observation['images']);
 
// autre élément de post-processing: le ce_utilisateur de l'observation non-numeric...
$this->nettoyerAuteur();
 
// Mettre en forme le résultat et l'envoyer pour affichage
$resultat = new ResultatService();
$resultat->corps = $this->observation;
return $resultat;
}
 
private function getInfosObservationEtImages() {
$obsChamps = $this->sql->getAliasDesChamps($this->mappings['observations'], null, 'dob');
$imgChamps = $this->sql->getAliasDesChamps($this->mappings['images'], null, 'dob');
 
// champs de l'annuaire (del_utilisateur): id_utilisateur prenom, nom, courriel
$annuaireChamps = implode(', ', array(
"IFNULL(du.prenom, prenom_utilisateur) AS `auteur.prenom`",
"IFNULL(du.nom, nom_utilisateur) AS `auteur.nom`",
"IFNULL(du.courriel, courriel_utilisateur) AS `auteur.courriel`"));
 
$requete = "SELECT $obsChamps, $imgChamps, $annuaireChamps ".
"FROM v_del_image as dob ".
"LEFT JOIN del_utilisateur AS du ".
" ON CAST(du.id_utilisateur AS CHAR) = CAST(dob.ce_utilisateur AS CHAR) ".
"WHERE dob.id_observation = {$this->idObs} ".
'-- '.__FILE__.':'.__LINE__;
//var_dump($requete);
return $this->bdd->recupererTous($requete);
}
 
private function formaterObservation($infos) {
$urlImgTpl = $this->conteneur->getParametre('cel_img_url_tpl');
$imageFormat = 'XL';
 
$infos = array_map('array_filter', $infos);
foreach ($infos as $info) {
$image = array_intersect_key($info, array_flip(array('id_image', 'date', 'hauteur' , 'largeur', 'nom_original')));
$image['binaire.href'] = sprintf($urlImgTpl, $image['id_image'], $imageFormat);
unset($info['id_image'], $info['date'], $info['hauteur'], $info['largeur'], $info['nom_original']);
 
if (!isset($this->observation)) $this->observation = $info;
$this->observation['images'][$image['id_image']] = $image;
}
}
 
private function getCommentaires() {
$selectVotes = array('id_vote', 'ce_proposition', 'ce_utilisateur', 'valeur', 'date');
$selectCommentaires = array('id_commentaire', 'ce_observation', 'ce_proposition', 'ce_commentaire_parent', 'texte',
'ce_utilisateur', 'utilisateur_prenom', 'utilisateur_nom', 'utilisateur_courriel',
'nom_sel', 'nom_sel_nn', 'nom_ret', 'nom_ret_nn', 'nt', 'famille', 'nom_referentiel', 'date',
'proposition_initiale');
 
$voteChamps = $this->sql->getAliasDesChamps($this->mappings['votes'], $selectVotes, 'cv');
$commentaireChamps = $this->sql->getAliasDesChamps($this->mappings['commentaires'], $selectCommentaires, 'dc');
 
// LEFT JOIN optionnel, mais explicatif : récupèration des infos de vote que pour les commentaires comportant un nom_sel "valide"
$requete = "SELECT $commentaireChamps, $voteChamps ".
"FROM del_commentaire AS dc ".
" LEFT JOIN del_commentaire_vote AS cv ".
" ON (cv.ce_proposition = dc.id_commentaire AND dc.nom_sel != '' AND dc.nom_sel IS NOT NULL) ".
"WHERE ce_observation = {$this->idObs} ".
'-- '.__FILE__.':'.__LINE__;
 
$commentaires = $this->bdd->recupererTous($requete);
return $commentaires;
 
}
 
private function ajouterCommentaires($commentaires) {
if (!$commentaires) return;
 
$ret = array();
foreach ($commentaires as $comment) {
$commentId = $comment['id_commentaire'];
$voteId = $comment['vote.id'];
 
if (!array_key_exists($commentId, $ret)) {
$comment_extract = array_intersect_key($comment, array_flip($this->mappings['commentaires']));
 
// cas particulier: conflit d'aliases avec del_commentaire_vote
$comment_extract['auteur.id'] = $comment_extract['__auteur_com'];
$comment_extract['date'] = $comment_extract['__date_com'];
unset($comment_extract['__auteur_com'], $comment_extract['__date_com']);
 
// toujours un éléments "votes", quand bien même il n'y en aurait pas
$comment_extract['votes'] = array();
$ret[$commentId] = $comment_extract;
}
 
if (!$comment['nom_sel'] || ! $voteId) continue;
$vote = array_intersect_key($comment, array_flip($this->mappings['votes']));
$ret[$commentId]['votes'][$voteId] = $vote;
}
$this->observation['commentaires'] = $ret;
}
 
private function nettoyerAuteur() {
if (!is_numeric($this->observation['auteur.id'])) {
$this->observation['auteur.id'] = '0';
}
if (!isset($this->observation['auteur.nom'])) {
$this->observation['auteur.nom'] = '[inconnu]';
}
}
 
/**
* Modifie une observation directement dans le CEL en faisant un appel à un web service du CEL.
* Utilisé uniquement par les admins.
* Permet de dépublier une observation.
*
* @param array $ressources tableau des informations contenues dans l'url après le nom du service
* @param array $parametres contenu du post
* @return mixed Chaine "OK" (en majuscule) en cas de succès, booléen "false" en cas d'échec
*/
public function modifier($ressources, $parametres) {
$controlAcces = $this->conteneur->getControleAcces();
$controlAcces->etreUtilisateurAvecDroitAdmin();
 
$retour = false;
if (isset($parametres['transmission'])) {
$idObs = $ressources[0];
$clientRest = $this->conteneur->getRestClient();
$urlTpl = $this->conteneur->getParametre('urlServiceCelObs');
$url = $urlTpl.$idObs;
$retourCel = $clientRest->modifier($url, $parametres);
$retour = preg_match('/^OK$/i', $retourCel) ? 'OK' : false;
if ($retour === false) {
$message = "Erreur du web service CEL : ".$retourCel;
$code = RestServeur::HTTP_CODE_MAUVAISE_REQUETE;
throw new Exception($message, $code);
}
} else {
$message = "Ce web service doit contenir un paramètre 'transmission'.";
$code = RestServeur::HTTP_CODE_MAUVAISE_REQUETE;
throw new Exception($message, $code);
}
return $retour;
}
}
/trunk/services/bibliotheque/Outils.php
File deleted
\ No newline at end of file
/trunk/services/bibliotheque/Navigation.php
155,8 → 155,8
 
$entete['total'] = $this->getTotal();
if ($this->sansLimite == false) {
$entete['depart'] = $this->getDepart();
$entete['limite'] = $this->getLimite();
$entete['depart'] = (int) $this->getDepart();
$entete['limite'] = (int) $this->getLimite();
 
$lienPrecedent = $this->recupererHrefPrecedent();
if ($lienPrecedent != null) {
/trunk/services/bibliotheque/Sql.php
New file
0,0 → 1,609
<?php
// declare(encoding='UTF-8');
/**
* Classe contenant des méthodes permettant de construire les requêtes SQL complexe concernant les images et obs.
*
* @category DEL
* @package Services
* @package Bibliotheque
* @version 0.1
* @author Mathias CHOUET <mathias@tela-botanica.org>
* @author Jean-Pascal MILCENT <jpm@tela-botanica.org>
* @author Aurelien PERONNET <aurelien@tela-botanica.org>
* @license GPL v3 <http://www.gnu.org/licenses/gpl.txt>
* @license CECILL v2 <http://www.cecill.info/licences/Licence_CeCILL_V2-en.txt>
* @copyright 1999-2014 Tela Botanica <accueil@tela-botanica.org>
*/
class Sql {
 
private $conteneur;
private $bdd;
private $parametres = array();
private $requete = array(
'select' => array(),
'join' => array(),
'where' => array(),
'groupby' => array(),
'orderby' => array());
 
private $champsPrenom = array('du.prenom', 'vdi.prenom_utilisateur');
private $champsNom = array('du.nom', 'vdi.nom_utilisateur');
 
 
public function __construct(Conteneur $conteneur) {
$this->conteneur = $conteneur;
$this->bdd = $this->conteneur->getBdd();
}
 
public function setParametres(Array $parametres) {
$this->parametres = $parametres;
}
 
public function getRequeteSql() {
return $this->requete;
}
 
private function addJoin($join) {
$this->requete['join'][] = $join;
}
 
public function getJoin() {
return ($this->requete['join'] ? implode(' ', array_unique($this->requete['join'])).' ' : '');
}
 
private function addJoinDis($join) {
$this->requete['join']['dis'] = $join;
}
 
private function addWhere($idParam, $where) {
if (isset($this->parametres['_parametres_condition_or_'])
&& in_array($idParam, $this->parametres['_parametres_condition_or_'])) {
$this->requete['where']['OR'][] = $where;
} else {
$this->requete['where']['AND'][] = $where;
}
}
public function getWhere() {
if (isset($this->requete['where']['OR']) && count($this->requete['where']['OR']) > 0) {
$this->requete['where']['AND'][] = '('.implode(' OR ', $this->requete['where']['OR']).')';
}
 
$where = ' TRUE ';
if (isset($this->requete['where']['AND']) && count($this->requete['where']['AND']) > 0) {
$where = implode(' AND ', $this->requete['where']['AND']).' ';
}
return $where;
}
 
private function addGroupBy($groupBy) {
$this->requete['groupby'][] = $groupBy;
}
 
public function getGroupBy() {
$groupby = '';
if (isset($this->requete['groupby']) && count($this->requete['groupby']) > 0) {
$groupby = 'GROUP BY '.implode(', ', array_unique($this->requete['groupby'])).' ';
}
return $groupby;
}
 
private function addOrderBy($orderby) {
$this->requete['orderby'][] = $orderby;
}
 
public function getOrderBy() {
$orderby = '';
if (isset($this->requete['orderby']) && count($this->requete['orderby']) > 0) {
$orderby = 'ORDER BY '.implode(', ', array_unique($this->requete['orderby'])).' ';
}
return $orderby;
}
 
public function getLimit() {
return 'LIMIT '.$this->parametres['navigation.depart'].','.$this->parametres['navigation.limite'].' ';
}
 
/**
* - Rempli le tableau des contraintes "where" et "join" nécessaire
* à la *recherche* des observations demandées ($req) utilisées par self::getIdObs()
*
* Attention, cela signifie que toutes les tables ne sont pas *forcément*
* join'ées, par exemple si aucune contrainte ne le nécessite.
* $req tel qu'il est rempli ici est utile pour récupéré la seule liste des
* id d'observation qui match.
* Pour la récupération effective de "toutes" les données correspondante, il faut
* réinitialiser $req["join"] afin d'y ajouter toutes les autres tables.
*
* Note: toujours rajouter les préfixes de table (vdi,du,doi ou di), en fonction de ce que défini
* les JOIN qui sont utilisés.
* le préfix de v_del_image est "vdi" (cf: "FROM" de self::getIdObs())
* le préfix de del_utilisateur sur id_utilisateur = vdi.ce_utilisateur est "du"
*
* @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
*/
public function ajouterContraintes() {
$this->ajouterContrainteAuteur();
$this->ajouterContrainteDate();
$this->ajouterContrainteDepartement();
$this->ajouterContrainteIdZoneGeo();
$this->ajouterContrainteGenre();
$this->ajouterContrainteFamille();
$this->ajouterContrainteNs();
$this->ajouterContrainteNn();
$this->ajouterContrainteReferentiel();
$this->ajouterContrainteCommune();
}
 
private function ajouterContrainteAuteur() {
if (isset($this->parametres['masque.auteur'])) {
$auteur = $this->parametres['masque.auteur'];
// id du poster de l'obs
$this->addJoin('LEFT JOIN del_utilisateur AS du ON (du.id_utilisateur = vdi.ce_utilisateur) ');
// id du poster de l'image... NON, c'est le même que le posteur de l'obs
// Cette jointure de table est ignoré ci-dessous pour les recherches d'auteurs
// $req['join'][] = 'LEFT JOIN del_utilisateur AS dui ON dui.id_utilisateur = vdi.i_ce_utilisateur';
 
if (is_numeric($auteur)) {
$this->ajouterContrainteAuteurId();
} elseif(preg_match('/^.{5,}@[a-z0-9-.]{5,}$/i', $auteur)) {
$this->ajouterContrainteAuteurEmail();
} else {
$this->ajouterContrainteAuteurIntitule();
}
}
}
 
private function ajouterContrainteAuteurId() {
$id = $this->parametres['masque.auteur'];
$sqlTpl = '(du.id_utilisateur = %1$d OR vdi.ce_utilisateur = %1$d)';
$whereAuteur = sprintf($sqlTpl, $id);
$this->addWhere('masque.auteur', $whereAuteur);
}
 
private function ajouterContrainteAuteurEmail() {
$email = $this->parametres['masque.auteur'];
$sqlTpl = '(du.courriel LIKE %1$s OR vdi.courriel LIKE %1$s )';
$emailP = $this->bdd->proteger("$email%");
$whereAuteur = sprintf($sqlTpl, $emailP);
$this->addWhere('masque.auteur', $whereAuteur);
}
 
/**
* Retourne une clause where du style:
* CONCAT(IF(du.prenom IS NULL, "", du.prenom), [...] vdi.i_nomutilisateur) REGEXP 'xxx'
* Note; i_(nom|prenom_utilisateur), alias pour cel_images.(nom|prenom), n'est pas traité
* car cette information est redondante dans cel_image et devrait être supprimée.
*/
private function ajouterContrainteAuteurIntitule() {
$auteurExplode = explode(' ', $this->parametres['masque.auteur']);
$nbreMots = count($auteurExplode);
 
if ($nbreMots == 1) {
$this->ajouterContrainteAuteurPrenomOuNom();
} else if ($nbreMots == 2) {
$this->ajouterContrainteAuteurPrenomEtNom();
}
}
 
private function ajouterContrainteAuteurPrenomOuNom() {
$prenomOuNom = $this->parametres['masque.auteur'];
 
$sqlTpl = 'CONCAT(%s,%s) LIKE %s';
$champsPrenomSql = self::ajouterIfNullPourConcat($this->champsPrenom);
$champsNomSql = self::ajouterIfNullPourConcat($this->champsNom);
$auteurMotif = $this->bdd->proteger("%$prenomOuNom%");
 
$auteurWhere = sprintf($sqlTpl, $champsPrenomSql, $champsNomSql, $auteurMotif);
$this->addWhere('masque.auteur', $auteurWhere);
}
 
private function ajouterContrainteAuteurPrenomEtNom() {
list($prenom, $nom) = explode(' ', $this->parametres['masque.auteur']);
 
$sqlTpl = '(CONCAT(%1$s,%2$s) LIKE %3$s AND CONCAT(%1$s,%2$s) LIKE %4$s)';
$champsPrenomSql = self::ajouterIfNullPourConcat($this->champsPrenom);
$champsNomSql = self::ajouterIfNullPourConcat($this->champsNom);
$prenomMotif = $this->bdd->proteger("%$prenom%");
$nomMotif = $this->bdd->proteger("%$nom%");
 
$auteurWhere = sprintf($sqlTpl, $champsPrenomSql, $champsNomSql, $prenomMotif, $nomMotif);
$this->addWhere('masque.auteur', $auteurWhere);
}
 
/**
* Lorsque l'on concatène des champs, un seul NULL prend le dessus,
* Il faut donc utiliser la syntaxe IFNULL(%s, "").
* (Cette fonction effectue aussi l'implode() "final"
*/
private static function ajouterIfNullPourConcat($champs) {
$champsProteges = array();
foreach ($champs as $champ) {
$champsProteges[] = "IFNULL($champ, '')";
}
return implode(',', $champsProteges);
}
 
private function ajouterContrainteDate() {
if (isset($this->parametres['masque.date'])) {
$date = $this->parametres['masque.date'];
if (is_integer($date) && $date < 2030 && $date > 1600) {
$sqlTpl = "YEAR(vdi.date_observation) = %d";
$dateWhere = sprintf($sqlTpl, $date);
$this->addWhere('masque.date', $dateWhere);
} else {
$sqlTpl = "DATE_FORMAT(vdi.date_observation, '%%Y-%%m-%%d') = %s";
$dateP = $this->bdd->proteger(strftime('%Y-%m-%d', $date));
$dateWhere = sprintf($sqlTpl, $dateP);
$this->addWhere('masque.date', $dateWhere);
}
}
}
 
private function ajouterContrainteDepartement() {
if (isset($this->parametres['masque.departement'])) {
$dept = $this->parametres['masque.departement'];
$deptMotif = $this->bdd->proteger("INSEE-C:$dept");
$this->addWhere('masque.departement', "vdi.ce_zone_geo LIKE $deptMotif");
}
}
 
private function ajouterContrainteIdZoneGeo() {
if (isset($this->parametres['masque.id_zone_geo'])) {
$idZgMotif = $this->bdd->proteger($this->parametres['masque.id_zone_geo']);
$this->addWhere('masque.id_zone_geo', "vdi.ce_zone_geo = $idZgMotif");
}
}
 
private function ajouterContrainteGenre() {
if (isset($this->parametres['masque.genre'])) {
$genre = $this->parametres['masque.genre'];
$genreMotif = $this->bdd->proteger("%$genre% %");
$this->addWhere('masque.genre', "vdi.nom_sel LIKE $genreMotif");
}
}
 
private function ajouterContrainteFamille() {
if (isset($this->parametres['masque.famille'])) {
$familleMotif = $this->bdd->proteger($this->parametres['masque.famille']);
$this->addWhere('masque.famille', "vdi.famille = $familleMotif");
}
}
 
private function ajouterContrainteNs() {
if (isset($this->parametres['masque.ns'])) {
$ns = $this->parametres['masque.ns'];
$nsMotif = $this->bdd->proteger("$ns%");
$this->addWhere('masque.ns', "vdi.nom_sel LIKE $nsMotif");
}
}
 
private function ajouterContrainteNn() {
if (isset($this->parametres['masque.nn'])) {
$sqlTpl = '(vdi.nom_sel_nn = %1$d OR vdi.nom_ret_nn = %1$d)';
$nnWhere = sprintf($sqlTpl, $this->parametres['masque.nn']);
$this->addWhere('masque.nn', $nnWhere);
}
}
 
private function ajouterContrainteReferentiel() {
if (isset($this->parametres['masque.referentiel'])) {
$ref = $this->parametres['masque.referentiel'];
$refMotif = $this->bdd->proteger("$ref%");
$this->addWhere('masque.referentiel', "vdi.nom_referentiel LIKE $refMotif");
}
}
 
private function ajouterContrainteCommune() {
if (isset($this->parametres['masque.commune'])) {
$commune = $this->parametres['masque.commune'];
$communeMotif = $this->bdd->proteger("$commune%");
$this->addWhere('masque.commune', "vdi.zone_geo LIKE $communeMotif");
}
}
 
/**
* in $p: un tableau de paramètres, dont:
* - 'masque.tag_cel': *tableau* de mots-clefs à chercher parmi cel_image.mots_clefs_texte
* - 'masque.tag_del': *tableau* de mots-clefs à chercher parmi del_image_tag.tag_normalise
* - 'tag_explode_semantic': défini si les éléments sont tous recherchés ou NON
*
* in/ou: $req: un tableau de structure de requête MySQL
*
* Attention, le fait que nous cherchions masque.tag_cel OU/ET masque.tag_cel
* ne dépend pas de nous, mais du niveau supérieur de construction de la requête:
* Soit directement $this->consulter() si des masque.tag* sont passés
* (split sur ",", "AND" entre chaque condition, "OR" pour chaque valeur de tag)
* Soit via sqlAddMasqueConstraint():
* (pas de split, "OR" entre chaque condition) [ comportement historique ]
* équivalent à:
* (split sur " ", "OR" entre chaque condition, "AND" pour chaque valeur de tag)
*
*/
public function ajouterConstrainteAppliImg() {
$this->ajouterContrainteMilieu();
$this->ajouterContrainteTri();
$this->ajouterContrainteTagCel();
$this->ajouterContrainteTagDel();
}
 
private function ajouterContrainteMilieu() {
if (isset($this->parametres['masque.milieu'])) {
$milieu = $this->parametres['masque.milieu'];
$milieuMotif = $this->bdd->proteger("%$milieu%");
$this->addWhere('masque.milieu', "vdi.milieu LIKE $milieuMotif");
}
}
 
/** Pour le tri par AVG() des votes nous avons toujours un protocole donné,
* celui-ci indique sur quels votes porte l'AVG.
* (c'est un *vote* qui porte sur un protocole et non l'image elle-même)
* TODO: perf problème:
* 1) SQL_CALC_FOUND_ROWS: fixable en:
* - dissociant le comptage de la récup d'id + javascript async
* - ou ne rafraîchir le total *que* pour les requête impliquant un changement de pagination
* (paramètre booléen "with-total" par exemple)
* 2) jointure forcées: en utilisant `del_imagè`, nous forçons les 2 premiers
* JOIN sur cel_obs_images et cel_obs pour filtrer sur "transmission".
* Dénormaliser cette valeur et l'intégrer à `cel_images` ferait économiser cette couteuse
* jointure, ... lorsqu'aucun masque portant sur `cel_obs` n'est utilisé
* 3) non-problème: l'ordre des joins est forcé par l'usage de la vue:
* (cel_images/cel_obs_images/cel_obs/del_image_stat)
* Cependant c'est à l'optimiseur de définir son ordre préféré.
*/
private function ajouterContrainteTri() {
if (isset($this->parametres['tri'])) {
$tri = $this->parametres['tri'];
 
if (isset($this->parametres['protocole']) && ($tri == 'votes' || $tri == 'points')) {
// $this->parametres['protocole'] *est* défini (cf Outils::filtrerUrlsParams...())
$sqlTpl = 'LEFT JOIN del_image_stat dis ON vdi.id_image = dis.ce_image AND dis.ce_protocole = %d';
$triSql = sprintf($sqlTpl, $this->parametres['protocole']);
$this->addJoinDis($triSql);
}
 
if (isset($this->parametres['ordre']) && $tri == 'tags') {
$typeJointure = ($this->parametres['ordre'] == 'desc') ? 'INNER' : 'LEFT';
$this->addJoin("$typeJointure JOIN del_image_stat dis ON vdi.id_image = dis.ce_image");
// nécessaire (dup ce_image dans del_image_stat)
$this->addGroupBy('vdi.id_observation');
}
}
}
 
/**
* Car il ne sont pas traités par la générique requestFilterParams() les clefs "masque.tag_*"
* sont toujours présentes; bien que parfois NULL.
*/
// TODO: utiliser les tables de mots clefs normaliées dans tb_cel ? et auquel cas laisser au client le choix du couteux "%" ?
private function ajouterContrainteTagCel() {
if ($this->parametres['masque.tag_cel']) {
if (isset($this->parametres['masque.tag_cel']['AND'])) {
$tags = $this->parametres['masque.tag_cel']['AND'];
$clausesWhere = array();
foreach ($tags as $tag) {
$tagMotif = $this->bdd->proteger("%$tag%");
$sqlTpl = "CONCAT(IFNULL(vdi.mots_cles_texte,''),IFNULL(vdi.i_mots_cles_texte,'')) LIKE %s";
$clausesWhere[] = sprintf($sqlTpl, $tagMotif);
}
$whereTags = implode(' AND ', $clausesWhere);
$this->addWhere('masque.tag_cel', "($whereTags)");
} else if (isset($this->parametres['masque.tag_cel']['OR'])) {
$tags = $this->parametres['masque.tag_cel']['OR'];
$sqlTpl = "CONCAT(IFNULL(vdi.mots_cles_texte,''),IFNULL(vdi.i_mots_cles_texte,'')) REGEXP %s";
$tagMotif = $this->bdd->proteger(implode('|', $tags));
$tagSql = sprintf($sqlTpl, $tagMotif);
$this->addWhere('masque.tag_cel', $tagSql);
}
}
}
 
/**
* Plusieurs solutions disponibles pour la gestion des contraintes des tags DEL :
* - inutilisable pour l'instant : ajouterContrainteTagDelSolution1();
* - intéressante, mais problème d'optimiseur MySQL 5.5 (dependant subquery) : ajouterContrainteTagDelSolution2();
* - approche fiable mais sous-optimale : ajouterContrainteTagDelSolution3();
*/
private function ajouterContrainteTagDel() {
if (isset($this->parametres['masque.tag_del'])) {
$this->ajouterContrainteTagDelSolution3();
}
}
 
/** Approche intéressante si les deux problèmes suivants peuvent être résolu:
* - LEFT JOIN => dup => *gestion de multiples GROUP BY* (car in-fine un LIMIT est utilisé)
* - dans le cas d'un ET logique, comment chercher les observations correspondantes ?
*/
private function ajouterContrainteTagDelSolution1() {
// XXX: utiliser tag plutôt que tag_normalise ?
$req['join'][] = 'LEFT JOIN del_image_tag dit ON dit.ce_image = vdi.id_image';
$req['where'][] = 'dit.actif = 1';
$req['groupby'][] = 'vdi.id_image'; // TODO: nécessaire (car dup') mais risque de conflict en cas de tri (multiple GROUP BY)
// XXX: en cas de ET, possibilité du GROUP_CONCAT(), mais probablement sans grand intérêt, d'où une boucle
if (isset($p['masque.tag_del']['AND'])) {
// TODO/XXX : comment matcher les observations ayant tous les mots-clef passés ?
// ... le LEFT-JOIN n'y semble pas adapté
} else {
$protected_tags = array();
foreach ($p['masque.tag_del']['OR'] as $tag) {
$protected_tags[] = $db->proteger(strtolower($tag));
}
$req['where'][] = sprintf('tag_normalise IN (%s)', implode(',', $protected_tags));
}
}
 
/**
* Inutilisé pour l'instant pour cause de soucis d'optimiseur MySQL (cf commentaire en intro)
*/
private function ajouterContrainteTagDelSolution2() {
// Note à propos des 4 "@ instruction" ci-dessous (notamment sur recupererTous())
// REGEXP permet un puissant mécanisme de sélection des obs/image à qui sait
// l'utiliser, mais peut sortir une erreur en cas de REGEXP invalide
// ex: REGEX "^(".
// Pour l'heure nous ignorons ce type d'erreur car aucun de nos champ de recherche
// ne peuvent (ou ne devrait) comporter des meta-caractères
// ([])?*+\\
if (isset($p['masque.tag_del']['AND'])) {
// optimsation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
// donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
sort($p['masque.tag_del']['AND']);
$req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP %s)",
$db->proteger(implode('.*', $p['masque.tag_del']['AND'])));
} else {
$req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
" GROUP BY ce_image".
" HAVING GROUP_CONCAT(tag_normalise) REGEXP %s)",
$db->proteger(implode('|', $p['masque.tag_del']['OR'])));
}
}
 
/**
* Si l'on est bassiné par les "DEPENDENT SUBQUERY", nous la faisons donc indépendemment via cette fonction
*/
private function ajouterContrainteTagDelSolution3() {
if (isset($this->parametres['masque.tag_del']['AND'])) {
$tags = $this->parametres['masque.tag_del']['AND'];
// optimisation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
// donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
sort($tags);
$tagsMotif = $this->bdd->proteger(implode('.*', $tags));
$requete = 'SELECT ce_image '.
'FROM del_image_tag '.
'WHERE actif = 1 '.
'GROUP BY ce_image '.
"HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP $tagsMotif ".
' -- '.__FILE__.' : '.__LINE__;
$sql = $this->recupererSqlContrainteTag($requete);
$this->addWhere('masque.tag_del', $sql);
 
} else if (isset($this->parametres['masque.tag_del']['OR'])) {
$tags = $this->parametres['masque.tag_del']['OR'];
$tagsMotif = $this->bdd->proteger(implode('|', $tags));
$requete = 'SELECT ce_image '.
'FROM del_image_tag '.
'WHERE actif = 1 '.
'GROUP BY ce_image '.
"HAVING GROUP_CONCAT(tag_normalise) REGEXP $tagsMotif ".
' -- '.__FILE__.' : '.__LINE__;
$sql = $this->recupererSqlContrainteTag($requete);
$this->addWhere('masque.tag_del', $sql);
}
}
 
private function recupererSqlContrainteTag($requete) {
$resultats = $this->bdd->recupererTous($requete);
$ids = array();
foreach ($resultats as $resultat) {
$ids[] = $resultat['ce_image'];
}
 
if (!empty($ids)) {
$clauseIn = implode(',', $ids);
} else {
$clauseIn = 'SELECT ce_image FROM del_image_tag WHERE false';
}
return "vdi.id_image IN ($clauseIn)";
}
 
/**
* Partie spécifique à PictoFlora:
* génération de la clause ORDER BY (génère la valeur de la clef orderby' de $req)
* nécessaire ? tableau sprintf(key (tri) => value (ordre), key => value ...).
* Cependant il est impensable de joindre sur un AVG() des valeurs des votes pour
* *chaque* couple (id_image, protocole) de la base afin de trouver les images
* les "mieux notées", ou bien les images ayant le "plus de tags" (COUNT())
*/
public function definirOrdreSqlAppliImg() {
$ordre = $this->parametres['ordre'];
 
// parmi self::$tri_possible
switch ($this->parametres['tri']) {
case 'votes' :
$this->addOrderBy("dis.moyenne $ordre, dis.nb_votes $ordre");
break;
case 'points' :
$this->addOrderBy("dis.nb_points $ordre, dis.moyenne $ordre, dis.nb_votes $ordre");
break;
case 'tags' :
$this->addOrderBy("dis.nb_tags $ordre");
break;
case 'date_observation' :
$this->addOrderBy("date_observation $ordre, id_observation $ordre");
break;
default:
$this->addOrderBy("date_transmission $ordre, id_observation $ordre");
}
}
 
public function getAliasDesChamps($champsEtAlias, $select = null, $prefix = null) {
$arr = ($select) ? array_intersect_key($champsEtAlias, array_flip($select)) : $champsEtAlias;
$keys = array_keys($arr);
 
if ($prefix) {
array_walk($keys, create_function('&$val, $k, $prefix', '$val = sprintf("%s.`%s`", $prefix, $val);'), $prefix);
} else {
array_walk($keys, create_function('&$val, $k', '$val = sprintf("`%s`", $val);'));
}
 
return implode(', ', array_map(create_function('$v, $k', 'return sprintf("%s AS `%s`", $k, $v);'), $arr, $keys));
}
 
// Charger les images et leurs votes associés
public function getVotesDesImages($idsImages, $protocole = null) {
if (!$idsImages) return;
 
$mappingVotes = $this->conteneur->getParametreTableau('votes.mapping');
$mappingProtocoles = $this->conteneur->getParametreTableau('protocoles.mapping');
$selectVotes = array('id_vote', 'ce_image', 'ce_protocole', 'ce_utilisateur', 'valeur', 'date');
$selectProtocole = array('id_protocole', 'intitule', 'descriptif', 'tag');
$voteChamps = $this->getAliasDesChamps($mappingVotes, $selectVotes, 'v'); // "v": cf alias dans la requête
$protoChamps = $this->getAliasDesChamps($mappingProtocoles, $selectProtocole, 'p');
$idImgsConcat = implode(',', $idsImages);
 
$requete = "SELECT $voteChamps, $protoChamps ".
'FROM del_image_vote AS v '.
' INNER JOIN del_image_protocole AS p ON (v.ce_protocole = p.id_protocole) '.
"WHERE v.ce_image IN ($idImgsConcat) ".
($protocole ? " AND v.ce_protocole = $protocole " : '').
"ORDER BY FIELD(v.ce_image, $idImgsConcat) ".
'-- '.__FILE__.':'.__LINE__;
 
return $this->bdd->recupererTous($requete);
}
 
/**
* Ajoute les informations sur le protocole et les votes aux images.
*
* ATTENTION : Subtilité, nous passons ici le tableau d'images indexé par id_image qui est bien
* plus pratique pour associer les vote à un tableau, puisque nous ne connaissons pas les id d'observation.
* Mais magiquement (par référence), cela va remplir notre tableau indexé par couple d'id (id_image, id_observation)
* cf ListeImages::reformateImagesDoubleIndex() à qui revient la tâche de créer ces deux versions
* simultanément lorsque c'est encore possible.
*/
public function ajouterInfosVotesProtocoles($votes, &$images) {
if (!$votes) return;
$mappingVotes = $this->conteneur->getParametreTableau('votes.mapping');
$mappingProtocoles = $this->conteneur->getParametreTableau('protocoles.mapping');
 
// pour chaque vote
foreach ($votes as $vote) {
$imgId = $vote['image.id'];
$protoId = $vote['protocole.id'];
 
if (!array_key_exists('protocoles_votes', $images[$imgId]) ||
!array_key_exists($protoId, $images[$imgId]['protocoles_votes'])) {
// extrait les champs spécifique au protocole (le LEFT JOIN de chargerVotesImage les ramène en doublons
$protocole = array_intersect_key($vote, array_flip($mappingProtocoles));
$images[$imgId]['protocoles_votes'][$protoId] = $protocole;
}
 
$chpsVotes = array('id_vote', 'ce_image', 'ce_utilisateur', 'valeur', 'date');
$voteSelection = array_intersect_key($mappingVotes, array_flip($chpsVotes));
$vote = array_intersect_key($vote, array_flip($voteSelection));
$images[$imgId]['protocoles_votes'][$protoId]['votes'][$vote['vote.id']] = $vote;
}
}
}
/trunk/services/bibliotheque/ParametresFiltrage.php
New file
0,0 → 1,453
<?php
// declare(encoding='UTF-8');
/**
* Classe contenant des méthodes de filtrage/formatage des paramètres de recherche passés dans l'URL.
*
* Cette classe filtre et formate les parametres passées dans l'URL et construit un tableau associatif contenant
* le résultat des filtrages/formatages et les infos nécessaires à la construction d'une requête SQL.
*
* @category DEL
* @package Services
* @package Bibliotheque
* @version 0.1
* @author Mathias CHOUET <mathias@tela-botanica.org>
* @author Jean-Pascal MILCENT <jpm@tela-botanica.org>
* @author Aurelien PERONNET <aurelien@tela-botanica.org>
* @license GPL v3 <http://www.gnu.org/licenses/gpl.txt>
* @license CECILL v2 <http://www.cecill.info/licences/Licence_CeCILL_V2-en.txt>
* @copyright 1999-2014 Tela Botanica <accueil@tela-botanica.org>
*/
class ParametresFiltrage {
 
const LISTE_OBS_MAX_RESULT_LIMIT = 1000;
const LISTE_OBS_MAX_ID_OBS = 10e7;
const LISTE_OBS_MAX_BDTFX_NT = 1000000; // SELECT MAX(num_taxonomique) FROM bdtfx_v2_00; // 44378 + 1000
const LISTE_OBS_MAX_BDTFX_NN = 1000000; // SELECT MAX(num_nom) FROM bdtfx_v2_00;// 120816 + 10000
 
private $conteneur;
private $contexte;
private $parametres = array();
private $parametresFiltres = array();
 
public function __construct($conteneur) {
$this->conteneur = $conteneur;
$this->contexte = $this->conteneur->getContexte();
$this->parametres = $this->contexte->getQS();
}
 
 
/**
* Construit un (vulgaire) abstract syntax tree:
* "AND" => [ "tag1", "tag2" ]
* Idéalement (avec un parser simple comme proposé par http://hoa-project.net/Literature/Hack/Compiler.html#Langage_PP)
* nous aurions:
* "AND" => [ "tag1", "tag2", "OR" => [ "tag3", "tag4" ] ]
*
* Ici nous devons traiter les cas suivants:
* tags séparés par des "ET/AND OU/OR", séparés par des espaces ou des virgules.
* Mais la chaîne peut aussi avoir été issue du "masque général" (la barre de recherche générique).
* ce qui implique des comportement par défaut différents afin de préserver la compatibilité.
*
* Théorie:
* 1) tags passés par "champ tag":
* - support du ET/OU, et explode par virgule.
* - si pas d'opérande détectée: "OU"
*
* 2) tags passés par "recherche générale":
* - support du ET/OU, et explode par whitespace.
* - si pas d'opérande détectée: "ET"
*
* La présence de $additional_sep s'explique car ET/OU sous-entendent une séparation par des espaces.
* Mais ce n'est pas toujours pertinent car: 1) la compatibilité suggère de considérer parfois
* la virgule comme séparateur et 2) les tags *peuvent* contenir des espaces. Par conséquent:
* * a,b,c => "a" $default_op "b" $default_op "c"
* * a,b AND c => "a" AND "b" AND "c"
* * a OR b AND c,d => "a" AND "b" AND "c" AND "d"
* C'est à dire par ordre décroissant de priorité:
* 1) opérande contenu dans la chaîne
* 2) opérande par défaut
* 3) les séparateurs présents sont substitués par l'opérande déterminée par 1) ou 2)
*
* // TODO: support des parenthèses, imbrications & co: "(", ")"
* // http://codehackit.blogspot.fr/2011/08/expression-parser-in-php.html
* // http://blog.angeloff.name/post/2012/08/05/php-recursive-patterns/
*
* @param $str: la chaîne à "parser"
* @param $operateur_par_defaut: "AND" ou "OR"
* @param $separateur_additionnel: séparateur de mots:
*/
public function construireTableauTags($str = NULL, $operateur_par_defaut, $separateur_additionnel = ',') {
if (!$str) return;
$op = $this->definirOperateurParDefaut($str, $operateur_par_defaut);
 
$mots = preg_split('/ (OR|AND|ET|OU) /', $str, -1, PREG_SPLIT_NO_EMPTY);
if ($separateur_additionnel) {
foreach ($mots as $index => $mot) {
$mot = trim($mot);
$mots_separes = preg_split("/$separateur_additionnel/", $mot, -1, PREG_SPLIT_NO_EMPTY);
$mots[$index] = array_shift($mots_separes);
$mots = array_merge($mots, $mots_separes);
}
}
$mots = array_filter($mots);
return array($op => $mots);
}
 
public function definirOperateurParDefaut($str, $operateur_par_defaut) {
$op = $operateur_par_defaut;
if (preg_match('/\b(ET|AND)\b/', $str)) {
$op = 'AND';
} else if(preg_match('/\b(OU|OR)\b/', $str)) {
$op = 'OR';
}
return $op;
}
 
public function filtrerUrlParamsAppliImg() {
$this->maintenirCompatibilitesParametres();
 
$parametresAutorises = $this->conteneur->getParametreTableau('images.masques_possibles');
$this->eliminerParametresInconnus($parametresAutorises);
 
$this->repartirMasqueGeneral();
 
$paramsParDefaut = $this->conteneur->getParametreTableau('images.parametres_valeurs_defaut');
$this->definirParametresDefauts($paramsParDefaut);
 
$this->filtrerUrlParamsGeneraux();
 
$trisPossibles = $this->conteneur->getParametreTableau('appli_img.tris_possibles');
$this->detruireParametreInvalide('tri', $trisPossibles);
$formatsImgPossibles = $this->conteneur->getParametreTableau('appli_img.img_formats_possibles');
$this->detruireParametreInvalide('format', $formatsImgPossibles);
$this->filtrerProtocole();
 
$this->supprimerParametresFiltresInvalides();
return $this->parametresFiltres;
}
 
public function filtrerUrlParamsAppliObs() {
$this->maintenirCompatibilitesParametres();
 
$parametresAutorises = $this->conteneur->getParametreTableau(('observations.masques_possibles'));
$this->eliminerParametresInconnus($parametresAutorises);
 
$this->repartirMasqueGeneral();
 
$paramsParDefaut = $this->conteneur->getParametreTableau('observations.parametres_valeurs_defaut');
$this->definirParametresDefauts($paramsParDefaut);
 
$this->filtrerUrlParamsGeneraux();
 
$trisPossibles = $this->conteneur->getParametre('appli_obs.tris_possibles');
$this->detruireParametreInvalide('tri', $trisPossibles);
 
$this->supprimerParametresFiltresInvalides();
return $this->parametresFiltres;
}
 
private function maintenirCompatibilitesParametres() {
$this->renommerParametres();
 
if (!isset($this->parametres['masque.tag_del']) && isset($this->parametres['masque.tag'])) {
$this->parametres['masque.tag_del'] = $this->parametres['masque.tag'];
}
}
 
private function renommerParametres() {
$renomages = array('masque.tag_pictoflora' => 'masque.tag_del');
foreach ($renomages as $ancienNom => $nouveauNom) {
if (isset($this->parametres[$ancienNom])) {
$this->parametres[$nouveauNom] = $this->parametres[$ancienNom];
unset($this->parametres[$ancienNom]);
}
}
}
 
/**
* Suppression de toutes les clefs NON présentes dans le paramètre de config : images|observations.masques_possibles
* @param array $parametresAutorises tableau des paramètres pouvant être utilisé dans l'url.
*/
private function eliminerParametresInconnus(Array $parametresAutorises = null) {
if ($parametresAutorises) {
$this->parametres = array_intersect_key($this->parametres, array_flip($parametresAutorises));
}
}
 
/**
* Les paramètres par défaut sont écrasés par ceux passés dans l'url.
*
* @param array $paramsParDefaut tableau associatif des paramètres d'url par défaut
*/
private function definirParametresDefauts(Array $paramsParDefaut) {
$this->parametres = array_merge($paramsParDefaut, $this->parametres);
}
 
/**
* "masque" ne fait jamais que faire une requête sur la plupart des champs, (presque) tous traités
* de manière identique à la seule différence que:
* 1) ils sont combinés par des "OU" logiques plutôt que des "ET".
* 2) les tags sont traités différemment pour conserver la compatibilité avec l'utilisation historique:
* Tous les mots-clefs doivent matcher et sont séparés par des espaces.
*/
private function repartirMasqueGeneral() {
if (isset($this->parametres['masque']) && !empty(trim($this->parametres['masque']))) {
$masqueGeneral = trim($this->parametres['masque']);
$masquesDetailCles = array('masque.auteur', 'masque.departement', 'masque.commune', 'masque.id_zone_geo',
'masque.ns', 'masque.famille', 'masque.date', 'masque.genre', 'masque.milieu');
 
// Suppression de la génération de SQL du masque général sur les champ spécifiques qui sont traités avec leur valeur propre.
foreach ($masquesDetailCles as $cle) {
if (isset($this->parametres[$cle]) === false) {
$this->parametres[$cle] = $masqueGeneral;
$this->parametresFiltres['_parametres_condition_or_'][] = $cle;
}
}
}
}
 
/**
* Filtre et valide les paramètres reconnus. Effectue *toute* la sanitization *sauf* l'escape-string
* Cette fonction est appelée:
* - une fois sur les champs de recherche avancées
* - une fois sur le masque général si celui-ci à été spécifié. Dans ce cas,
* la chaîne générale saisie est utilisée comme valeur pour chacun des champs particuliers
* avec les traitements particuliers qui s'imposent
* Par exemple: si l'on cherche "Languedoc", cela impliquera:
* WHERE (nom_sel like "Languedoc" OR nom_ret ... OR ...) mais pas masque.date ou masque.departement
* qui s'assure d'un pattern particulier
*
* masque.genre est un alias pour masque.ns (nom_sel), mais permet de rajouter une clause supplémentaire
* sur nom_sel. Précédemment: WHERE nom_sel LIKE '%<masque.genre>% %'.
* Désormais masque.genre doit être intégralement spécifié, les caractères '%' et '_' seront interprétés.
* Attention toutefois car la table del_observation intègre des nom_sel contenant '_'
*/
// TODO: ajouter un filtre sur le masque (général)
private function filtrerUrlParamsGeneraux() {
$this->detruireParametreInvalide('ordre', $this->conteneur->getParametreTableau('valeurs_ordre'));
$this->detruireParametreInvalide('masque.referentiel', $this->conteneur->getParametreTableau('valeurs_referentiel'));
 
$this->filtrerNavigationLimite();
$this->filtrerNavigationDepart();
$this->filtrerDepartement();
$this->filtrerDate();
$this->filtrerNn();
$this->filtrerNt();
 
$parametresATrimer = array('masque', 'masque.ns', 'masque.genre', 'masque.espece', 'masque.auteur', 'masque.milieu');
$this->supprimerCaracteresInvisibles($parametresATrimer);
 
$this->filtrerFamille();
$this->filtrerIdZoneGeo();
$this->filtrerCommune();
$this->filtrerType();
 
$this->filtrerTag();
$this->filtrerTagCel();
$this->filtrerTagDel();
}
 
 
/**
* Supprime l'index du tableau des paramètres si sa valeur ne correspond pas
* au spectre passé par $values.
*/
private function detruireParametreInvalide($index, Array $valeursAutorisees) {
if (array_key_exists($index, $this->parametres)) {
if (!in_array($this->parametres[$index], $valeursAutorisees)) {
unset($this->parametres[$index]);
} else {
$this->parametresFiltres[$index] = $this->parametres[$index];
}
}
}
 
private function filtrerNavigationLimite() {
if (isset($this->parametres['navigation.limite'])) {
$options = array(
'options' => array(
'default' => null,
'min_range' => 1,
'max_range' => self::LISTE_OBS_MAX_RESULT_LIMIT));
$paramFiltre = filter_var($this->parametres['navigation.limite'], FILTER_VALIDATE_INT, $options);
$this->parametresFiltres['navigation.limite'] = $paramFiltre;
}
}
 
private function filtrerNavigationDepart() {
if (isset($this->parametres['navigation.depart'])) {
$options = array(
'options' => array(
'default' => null,
'min_range' => 0,
'max_range' => self::LISTE_OBS_MAX_ID_OBS));
$paramFiltre = filter_var($this->parametres['navigation.depart'], FILTER_VALIDATE_INT, $options);
$this->parametresFiltres['navigation.depart'] = $paramFiltre;
}
}
 
/**
* STRING: 0 -> 95, 971 -> 976, 2A + 2B (./services/configurations/config_departements_bruts.ini)
* accept leading 0 ?
* TODO; filter patterns like 555.
*
* @return type
*/
private function filtrerDepartement() {
if (isset($this->parametres['masque.departement'])) {
$dept = $this->parametres['masque.departement'];
$paramFiltre = null;
if (preg_match('/^(\d{2}|\d{3}|2a|2b)$/i', $dept) != 0) {
$paramFiltre = is_numeric($dept) ? str_pad($dept, 5, '_') : $dept;
} else {
$dept_translit = iconv('UTF-8', 'ASCII//TRANSLIT', $dept);
$dpt_chaine = strtolower(str_replace(' ', '-', $dept_translit));
$this->conteneur->chargerConfiguration('config_departements_bruts.ini');
$dpt_numero = $this->conteneur->getParametre($dpt_chaine);
if (!empty($dpt_numero)) {
$paramFiltre = str_pad($dpt_numero, 5, '_');
}
}
$this->parametresFiltres['masque.departement'] = $paramFiltre;
}
}
 
private function filtrerDate() {
if (isset($this->parametres['masque.date'])) {
$date = $this->parametres['masque.date'];
// une année, TODO: masque.annee
$paramFiltre = null;
if (is_numeric($date)) {
$paramFiltre = $date;
} elseif(strpos($date, '/' !== false) && ($x = strtotime(str_replace('/', '-', $date)))) {
$paramFiltre = $x;
} elseif(strpos($date, '-' !== false) && ($x = strtotime($date)) ) {
$paramFiltre = $x;
}
$this->parametresFiltres['masque.date'] = $paramFiltre;
}
}
 
private function filtrerNn() {
if (isset($this->parametres['masque.nn'])) {
$options = array(
'options' => array(
'default' => null,
'min_range' => 0,
'max_range' => self::LISTE_OBS_MAX_BDTFX_NN));
$paramFiltre = filter_var($this->parametres['masque.nn'], FILTER_VALIDATE_INT, $options);
$this->parametresFiltres['masque.nn'] = $paramFiltre;
}
}
 
private function filtrerNt() {
if (isset($this->parametres['masque.nt'])) {
$options = array(
'options' => array(
'default' => null,
'min_range' => 0,
'max_range' => self::LISTE_OBS_MAX_BDTFX_NT));
$paramFiltre = filter_var($this->parametres['masque.nt'], FILTER_VALIDATE_INT, $options);
$this->parametresFiltres['masque.nt'] = $paramFiltre;
}
}
 
private function supprimerCaracteresInvisibles(Array $liste_params) {
foreach ($liste_params as $param) {
if (isset($this->parametres[$param])) {
$this->parametresFiltres[$param] = trim($this->parametres[$param]);
}
}
}
 
private function filtrerFamille() {
if (isset($this->parametres['masque.famille'])) {
// mysql -N<<<"SELECT DISTINCT famille FROM bdtfx_v1_02;"|sed -r "s/(.)/\1\n/g"|sort -u|tr -d "\n"
$familleTranslit = iconv('UTF-8', 'ASCII//TRANSLIT',$this->parametres['masque.famille']);
$paramFiltre = preg_replace('/[^a-zA-Z %_]/', '', $familleTranslit);
$this->parametresFiltres['masque.famille'] = $paramFiltre;
}
}
 
// Idem pour id_zone_geo qui mappait à ce_zone_geo:
private function filtrerIdZoneGeo() {
if (isset($this->parametres['masque.id_zone_geo'])) {
if (preg_match('/^(INSEE-C:\d{5}|\d{2})$/', $this->parametres['masque.id_zone_geo'])) {
$paramFiltre = $this->parametres['masque.id_zone_geo'];
$this->parametresFiltres['masque.id_zone_geo'] = $paramFiltre;
}
}
}
 
/** masque.commune (zone_geo)
* TODO: que faire avec des '%' en INPUT ?
* Le masque doit *permettre* une regexp et non l'imposer. Charge au client de faire son travail.
*/
private function filtrerCommune() {
if (isset($this->parametres['masque.commune'])) {
$paramFiltre = str_replace(array('-',' '), '_', $this->parametres['masque.commune']);
$this->parametresFiltres['masque.commune'] = $paramFiltre;
}
}
 
// masque.tag, idem que pour masque.genre et masque.commune
private function filtrerTag() {
if (isset($this->parametres['masque.tag'])) {
$tagsArray = explode(',', $this->parametres['masque.tag']);
$tagsTrimes = array_map('trim', $tagsArray);
$tagsFiltres = array_filter($tagsTrimes);
$paramFiltre = implode('|', $tagsFiltres);
$this->parametresFiltres['masque.tag'] = $paramFiltre;
}
}
 
private function filtrerTagCel() {
if (isset($this->parametres['masque.tag_cel'])) {
$this->parametresFiltres['masque.tag_cel'] = $this->construireTableauTags($this->parametres['masque.tag_cel'], 'OR', ',');
} else if (isset($this->parametres['masque'])) {
$this->parametresFiltres['masque.tag_cel'] = $this->construireTableauTags($this->parametres['masque'], 'AND', ' ');
$this->parametresFiltres['_parametres_condition_or_'][] = 'masque.tag_cel';
}
}
 
private function filtrerTagDel() {
if (isset($this->parametres['masque.tag_del'])) {
$this->parametresFiltres['masque.tag_del'] = $this->construireTableauTags($this->parametres['masque.tag_del'], 'OR', ',');
} else if (isset($this->parametres['masque'])) {
$this->parametresFiltres['masque.tag_del'] = $this->construireTableauTags($this->parametres['masque'], 'AND', ' ');
$this->parametresFiltres['_parametres_condition_or_'][] = 'masque.tag_del';
}
}
 
// masque.type: ['adeterminer', 'aconfirmer', 'endiscussion', 'validees']
private function filtrerType() {
if(isset($this->parametres['masque.type'])) {
$typesArray = explode(';', $this->parametres['masque.type']);
$typesFiltres = array_filter($typesArray);
$typesAutorises = array('adeterminer', 'aconfirmer', 'endiscussion', 'validees');
$typesValides = array_intersect($typesFiltres, $typesAutorises);
$paramFiltre = array_flip($typesValides);
$this->parametresFiltres['masque.type'] = $paramFiltre;
}
}
 
private function filtrerProtocole() {
// ces critère de tri des image à privilégier ne s'applique qu'à un protocole donné
if (!isset($this->parametres['protocole']) || !is_numeric($this->parametres['protocole'])) {
$this->parametresFiltres['protocole'] = $this->conteneur->getParametre('appli_img.protocole_defaut');
} else {
$this->parametresFiltres['protocole'] = intval($this->parametres['protocole']);
}
}
 
private function supprimerParametresFiltresInvalides() {
// Suppression des NULL, FALSE et '', mais pas des 0, d'où l'utilisation de 'strlen'.
// La fonction 'strlen' permet de supprimer les NULL, FALSE et chaines vides mais gardent les valeurs 0 (zéro).
// Les valeurs spéciales contenant des tableaux (tag, _parametres_condition_or_) ne sont pas prise en compte
foreach ($this->parametresFiltres as $cle => $valeur) {
if (is_array($valeur) || strlen($valeur) !== 0) {
$this->parametresFiltres[$cle] = $valeur;
}
}
}
}
/trunk/services/bibliotheque/Conteneur.php
148,4 → 148,18
}
return $this->partages['syndicationOutils'];
}
 
public function getParametresFiltrage() {
if (!isset($this->partages['parametresFiltrage'])) {
$this->partages['parametresFiltrage'] = new ParametresFiltrage($this);
}
return $this->partages['parametresFiltrage'];
}
 
public function getSql() {
if (!isset($this->partages['sql'])) {
$this->partages['sql'] = new Sql($this);
}
return $this->partages['sql'];
}
}
/trunk/services/bibliotheque/SyndicationOutils.php
58,8 → 58,7
 
public function getUrlImage($id, $format = 'L') {
$url_tpl = $this->conteneur->getParametre('cel_img_url_tpl');
$id = sprintf('%09s', $id).$format;
$url = sprintf($url_tpl, $id);
$url = sprintf($url_tpl, $id, $format);
return $url;
}