From 9fb29ec110f9a5b9a2dda1b7d1267e66bb9d01df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 22 Nov 2021 08:28:22 +0000 Subject: [PATCH] refactor search for using search by pertinence --- CHANGELOG.md | 25 +- phpstan-types.neon | 5 - .../Activity/showInNotification.html.twig | 2 - .../migrations/Version20211119173555.php | 33 ++ .../migrations/Version20211119173557.php | 33 ++ .../migrations/Version20211119173556.php | 32 ++ .../migrations/Version20211119173558.php | 34 ++ .../ChillMainBundle/ChillMainBundle.php | 3 + .../Resources/views/layout.html.twig | 5 +- .../ChillMainBundle/Search/AbstractSearch.php | 30 +- .../ChillMainBundle/Search/SearchApi.php | 32 +- .../ChillMainBundle/Search/SearchApiQuery.php | 125 +++++-- .../ChillMainBundle/Search/SearchProvider.php | 65 ++-- .../Search/Utils/ExtractDateFromPattern.php | 36 ++ .../Utils/ExtractPhonenumberFromPattern.php | 54 +++ .../Search/Utils/SearchExtractionResult.php | 30 ++ .../Tests/Search/SearchApiQueryTest.php | 22 +- .../Tests/Search/SearchProviderTest.php | 127 ++++--- .../Utils/ExtractDateFromPatternTest.php | 45 +++ .../ExtractPhonenumberFromPatternTest.php | 33 ++ .../config/services/search.yaml | 11 +- .../migrations/Version20211119173554.php | 35 ++ .../translations/messages.fr.yml | 6 +- .../ChillPersonExtension.php | 7 - .../DependencyInjection/Configuration.php | 13 - .../Entity/Household/Household.php | 1 - .../ChillPersonBundle/Entity/Person.php | 10 +- .../ChillPersonBundle/Entity/PersonPhone.php | 7 +- .../Form/Type/GenderType.php | 2 +- .../Repository/PersonACLAwareRepository.php | 346 +++++++++--------- .../PersonACLAwareRepositoryInterface.php | 44 ++- .../views/Person/list_with_period.html.twig | 97 ++++- .../ChillPersonBundle/Search/PersonSearch.php | 72 +++- .../Search/PersonSearchByPhone.php | 133 ------- .../Search/SearchPersonApiProvider.php | 75 ++-- .../Search/SimilarityPersonSearch.php | 114 ------ .../config/services/search.yaml | 5 - .../config/services/search_by_phone.yaml | 11 - .../migrations/Version20211119211101.php | 28 ++ .../translations/messages+intl-icu.fr.yaml | 8 + .../translations/messages.fr.yml | 2 + 41 files changed, 1071 insertions(+), 727 deletions(-) create mode 100644 src/Bundle/ChillActivityBundle/migrations/Version20211119173555.php create mode 100644 src/Bundle/ChillCalendarBundle/migrations/Version20211119173557.php create mode 100644 src/Bundle/ChillDocGeneratorBundle/migrations/Version20211119173556.php create mode 100644 src/Bundle/ChillDocStoreBundle/migrations/Version20211119173558.php create mode 100644 src/Bundle/ChillMainBundle/Search/Utils/ExtractDateFromPattern.php create mode 100644 src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php create mode 100644 src/Bundle/ChillMainBundle/Search/Utils/SearchExtractionResult.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractDateFromPatternTest.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20211119173554.php delete mode 100644 src/Bundle/ChillPersonBundle/Search/PersonSearchByPhone.php delete mode 100644 src/Bundle/ChillPersonBundle/Search/SimilarityPersonSearch.php delete mode 100644 src/Bundle/ChillPersonBundle/config/services/search_by_phone.yaml create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20211119211101.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 903d17dd8..947649b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,28 @@ and this project adheres to ## Unreleased + +* [task] Select2 field in task form to allow search for a user (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/167) +* remove "search by phone configuration option": search by phone is now executed by default +* remplacer le classement par ordre alphabétique par un classement par ordre de pertinence, qui tient compte: + * de la présence d'une string avec le nom de la ville; + * de la similarité; + * du fait que la recherche commence par une partie du mot recherché +* ajouter la recherche par numéro de téléphone directement dans la barre de recherche et dans le formulaire recherche avancée; +* ajouter la recherche par date de naissance directement dans la barre de recherche; +* ajouter la recherche par ville dans la recherche avancée +* ajouter un lien vers le ménage dans les résultats de recherche +* ajouter l'id du parcours dans les résultats de recherche +* ajouter le demandeur dans les résultats de recherche +* ajout d'un bouton "recherche avancée" sur la page d'accueil * [person] create an accompanying course: add client-side validation if no origin (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/210) * [person] fix bounds for computing current person address: the new address appears immediatly + + +## Test releases + +### Test release 2021-11-15 + * [main] fix adding multiple AddresseDeRelais (combine PickAddressType with ChillCollection) * [person]: do not suggest the current household of the person (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/51) * [person]: display other phone numbers in view + add message in case no others phone numbers (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/184) @@ -26,11 +46,6 @@ and this project adheres to * [person suggest] In widget "add person", improve the pertinence of persons when one of the names starts with the pattern; * [person] do not ask for center any more on person creation * [3party] do not ask for center any more on 3party creation -* [task] Select2 field in task form to allow search for a user (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/167) -* [accompanying course] Add associated persons in banner details. social-issues and associated-persons are slides in same space. - - -## Test releases ### Test release 2021-11-08 diff --git a/phpstan-types.neon b/phpstan-types.neon index f10574600..dca84e17f 100644 --- a/phpstan-types.neon +++ b/phpstan-types.neon @@ -705,11 +705,6 @@ parameters: count: 1 path: src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php - - - message: "#^Method Chill\\\\PersonBundle\\\\Search\\\\SimilarityPersonSearch\\:\\:renderResult\\(\\) should return string but return statement is missing\\.$#" - count: 1 - path: src/Bundle/ChillPersonBundle/Search/SimilarityPersonSearch.php - - message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#" count: 1 diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig index 5128e9a64..79badfe26 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig @@ -1,4 +1,2 @@ -{{ dump(notification) }} - Go to Activity diff --git a/src/Bundle/ChillActivityBundle/migrations/Version20211119173555.php b/src/Bundle/ChillActivityBundle/migrations/Version20211119173555.php new file mode 100644 index 000000000..d39613455 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/migrations/Version20211119173555.php @@ -0,0 +1,33 @@ +addSql("COMMENT ON COLUMN $col IS NULL"); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20211119173557.php b/src/Bundle/ChillCalendarBundle/migrations/Version20211119173557.php new file mode 100644 index 000000000..002bfd1ae --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20211119173557.php @@ -0,0 +1,33 @@ +addSql("COMMENT ON COLUMN $col IS NULL"); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException(); + } +} diff --git a/src/Bundle/ChillDocGeneratorBundle/migrations/Version20211119173556.php b/src/Bundle/ChillDocGeneratorBundle/migrations/Version20211119173556.php new file mode 100644 index 000000000..947628ef8 --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/migrations/Version20211119173556.php @@ -0,0 +1,32 @@ +addSql("COMMENT ON COLUMN $col IS NULL"); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException(); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20211119173558.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20211119173558.php new file mode 100644 index 000000000..a66ddd52e --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20211119173558.php @@ -0,0 +1,34 @@ +addSql("COMMENT ON COLUMN $col IS NULL"); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException(); + } +} diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index f9e79bc8b..c6c42d36b 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -3,6 +3,7 @@ namespace Chill\MainBundle; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\MainBundle\Search\SearchApiInterface; use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Security\Authorization\ChillVoterInterface; use Chill\MainBundle\Security\ProvideRoleInterface; @@ -41,6 +42,8 @@ class ChillMainBundle extends Bundle ->addTag('chill_main.scope_resolver'); $container->registerForAutoconfiguration(ChillEntityRenderInterface::class) ->addTag('chill.render_entity'); + $container->registerForAutoconfiguration(SearchApiInterface::class) + ->addTag('chill.search_api_provider'); $container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass()); diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 1e78dbbe8..07b7970d8 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -5,7 +5,7 @@ - {{ installation.name }} - {% block title %}{% endblock %} + {{ installation.name }} - {% block title %}{{ 'Homepage'|trans }}{% endblock %} {{ encore_entry_link_tags('mod_bootstrap') }} @@ -68,6 +68,9 @@ + + {{ 'Advanced search'|trans }} + diff --git a/src/Bundle/ChillMainBundle/Search/AbstractSearch.php b/src/Bundle/ChillMainBundle/Search/AbstractSearch.php index 942819a39..1f32d3d16 100644 --- a/src/Bundle/ChillMainBundle/Search/AbstractSearch.php +++ b/src/Bundle/ChillMainBundle/Search/AbstractSearch.php @@ -26,10 +26,10 @@ use Chill\MainBundle\Search\ParsingException; /** * This class implements abstract search with most common responses. - * + * * you should use this abstract class instead of SearchInterface : if the signature of * search interface change, the generic method will be implemented here. - * + * * @author Julien Fastré * */ @@ -37,7 +37,7 @@ abstract class AbstractSearch implements SearchInterface { /** * parse string expected to be a date and transform to a DateTime object - * + * * @param type $string * @return \DateTime * @throws ParsingException if the date is not parseable @@ -51,14 +51,14 @@ abstract class AbstractSearch implements SearchInterface . 'not parsable', 0, $ex); throw $exception; } - + } - + /** * recompose a pattern, retaining only supported terms - * + * * the outputted string should be used to show users their search - * + * * @param array $terms * @param array $supportedTerms * @param string $domain if your domain is NULL, you should set NULL. You should set used domain instead @@ -67,35 +67,35 @@ abstract class AbstractSearch implements SearchInterface protected function recomposePattern(array $terms, array $supportedTerms, $domain = NULL) { $recomposed = ''; - + if ($domain !== NULL) { $recomposed .= '@'.$domain.' '; } - + foreach ($supportedTerms as $term) { if (array_key_exists($term, $terms) && $term !== '_default') { $recomposed .= ' '.$term.':'; $containsSpace = \strpos($terms[$term], " ") !== false; if ($containsSpace) { - $recomposed .= "("; + $recomposed .= '"'; } $recomposed .= (mb_stristr(' ', $terms[$term]) === FALSE) ? $terms[$term] : '('.$terms[$term].')'; if ($containsSpace) { - $recomposed .= ")"; + $recomposed .= '"'; } } } - + if ($terms['_default'] !== '') { $recomposed .= ' '.$terms['_default']; } - + //strip first character if empty if (mb_strcut($recomposed, 0, 1) === ' '){ $recomposed = mb_strcut($recomposed, 1); } - + return $recomposed; } -} \ No newline at end of file +} diff --git a/src/Bundle/ChillMainBundle/Search/SearchApi.php b/src/Bundle/ChillMainBundle/Search/SearchApi.php index 30ecb8d3d..c3bfc8d03 100644 --- a/src/Bundle/ChillMainBundle/Search/SearchApi.php +++ b/src/Bundle/ChillMainBundle/Search/SearchApi.php @@ -20,19 +20,15 @@ class SearchApi private EntityManagerInterface $em; private PaginatorFactory $paginator; - private array $providers = []; + private iterable $providers = []; public function __construct( EntityManagerInterface $em, - SearchPersonApiProvider $searchPerson, - ThirdPartyApiSearch $thirdPartyApiSearch, - SearchUserApiProvider $searchUser, + iterable $providers, PaginatorFactory $paginator ) { $this->em = $em; - $this->providers[] = $searchPerson; - $this->providers[] = $thirdPartyApiSearch; - $this->providers[] = $searchUser; + $this->providers = $providers; $this->paginator = $paginator; } @@ -68,10 +64,15 @@ class SearchApi private function findProviders(string $pattern, array $types, array $parameters): array { - return \array_filter( - $this->providers, - fn($p) => $p->supportsTypes($pattern, $types, $parameters) - ); + $providers = []; + + foreach ($this->providers as $provider) { + if ($provider->supportsTypes($pattern, $types, $parameters)) { + $providers[] = $provider; + } + } + + return $providers; } private function countItems($providers, $types, $parameters): int @@ -82,12 +83,12 @@ class SearchApi $countNq = $this->em->createNativeQuery($countQuery, $rsmCount); $countNq->setParameters($parameters); - return $countNq->getSingleScalarResult(); + return (int) $countNq->getSingleScalarResult(); } private function buildCountQuery(array $queries, $types, $parameters) { - $query = "SELECT COUNT(*) AS count FROM ({union_unordered}) AS sq"; + $query = "SELECT SUM(c) AS count FROM ({union_unordered}) AS sq"; $unions = []; $parameters = []; @@ -141,17 +142,20 @@ class SearchApi private function prepareProviders(array $rawResults) { $metadatas = []; + $providers = []; + foreach ($rawResults as $r) { foreach ($this->providers as $k => $p) { if ($p->supportsResult($r['key'], $r['metadata'])) { $metadatas[$k][] = $r['metadata']; + $providers[$k] = $p; break; } } } foreach ($metadatas as $k => $m) { - $this->providers[$k]->prepare($m); + $providers[$k]->prepare($m); } } diff --git a/src/Bundle/ChillMainBundle/Search/SearchApiQuery.php b/src/Bundle/ChillMainBundle/Search/SearchApiQuery.php index 095d49b43..8d656aa7c 100644 --- a/src/Bundle/ChillMainBundle/Search/SearchApiQuery.php +++ b/src/Bundle/ChillMainBundle/Search/SearchApiQuery.php @@ -4,6 +4,8 @@ namespace Chill\MainBundle\Search; class SearchApiQuery { + private array $select = []; + private array $selectParams = []; private ?string $selectKey = null; private array $selectKeyParams = []; private ?string $jsonbMetadata = null; @@ -15,6 +17,38 @@ class SearchApiQuery private array $whereClauses = []; private array $whereClausesParams = []; + public function addSelectClause(string $select, array $params = []): self + { + $this->select[] = $select; + $this->selectParams = [...$this->selectParams, ...$params]; + + return $this; + } + + public function resetSelectClause(): self + { + $this->select = []; + $this->selectParams = []; + $this->selectKey = null; + $this->selectKeyParams = []; + $this->jsonbMetadata = null; + $this->jsonbMetadataParams = []; + $this->pertinence = null; + $this->pertinenceParams = []; + + return $this; + } + + public function getSelectClauses(): array + { + return $this->select; + } + + public function getSelectParams(): array + { + return $this->selectParams; + } + public function setSelectKey(string $selectKey, array $params = []): self { $this->selectKey = $selectKey; @@ -47,6 +81,16 @@ class SearchApiQuery return $this; } + public function getFromClause(): string + { + return $this->fromClause; + } + + public function getFromParams(): array + { + return $this->fromClauseParams; + } + /** * Set the where clause and replace all existing ones. * @@ -54,7 +98,7 @@ class SearchApiQuery public function setWhereClauses(string $whereClause, array $params = []): self { $this->whereClauses = [$whereClause]; - $this->whereClausesParams = [$params]; + $this->whereClausesParams = $params; return $this; } @@ -71,11 +115,53 @@ class SearchApiQuery public function andWhereClause(string $whereClause, array $params = []): self { $this->whereClauses[] = $whereClause; - $this->whereClausesParams[] = $params; + \array_push($this->whereClausesParams, ...$params); return $this; } + private function buildSelectParams(bool $count = false): array + { + if ($count) { + return []; + } + + $args = $this->getSelectParams(); + + if (null !== $this->selectKey) { + $args = [...$args, ...$this->selectKeyParams]; + } + if (null !== $this->jsonbMetadata) { + $args = [...$args, ...$this->jsonbMetadataParams]; + } + if (null !== $this->pertinence) { + $args = [...$args, ...$this->pertinenceParams]; + } + + return $args; + } + + private function buildSelectClause(bool $countOnly = false): string + { + if ($countOnly) { + return 'count(*) AS c'; + } + + $selects = $this->getSelectClauses(); + + if (null !== $this->selectKey) { + $selects[] = \strtr("'{key}' AS key", [ '{key}' => $this->selectKey ]); + } + if (null !== $this->jsonbMetadata) { + $selects[] = \strtr('{metadata} AS metadata', [ '{metadata}' => $this->jsonbMetadata]); + } + if (null !== $this->pertinence) { + $selects[] = \strtr('{pertinence} AS pertinence', [ '{pertinence}' => $this->pertinence]); + } + + return \implode(', ', $selects); + } + public function buildQuery(bool $countOnly = false): string { $isMultiple = count($this->whereClauses); @@ -87,19 +173,8 @@ class SearchApiQuery ($isMultiple ? ')' : '') ; - if (!$countOnly) { - $select = \strtr(" - '{key}' AS key, - {metadata} AS metadata, - {pertinence} AS pertinence - ", [ - '{key}' => $this->selectKey, - '{metadata}' => $this->jsonbMetadata, - '{pertinence}' => $this->pertinence, - ]); - } else { - $select = "1 AS c"; - } + $select = $this->buildSelectClause($countOnly); + return \strtr("SELECT {select} @@ -116,18 +191,16 @@ class SearchApiQuery public function buildParameters(bool $countOnly = false): array { if (!$countOnly) { - return \array_merge( - $this->selectKeyParams, - $this->jsonbMetadataParams, - $this->pertinenceParams, - $this->fromClauseParams, - \array_merge([], ...$this->whereClausesParams), - ); + return [ + ...$this->buildSelectParams($countOnly), + ...$this->fromClauseParams, + ...$this->whereClausesParams, + ]; } else { - return \array_merge( - $this->fromClauseParams, - \array_merge([], ...$this->whereClausesParams), - ); + return [ + ...$this->fromClauseParams, + ...$this->whereClausesParams, + ]; } } } diff --git a/src/Bundle/ChillMainBundle/Search/SearchProvider.php b/src/Bundle/ChillMainBundle/Search/SearchProvider.php index ca5d169fa..3d02579bb 100644 --- a/src/Bundle/ChillMainBundle/Search/SearchProvider.php +++ b/src/Bundle/ChillMainBundle/Search/SearchProvider.php @@ -10,10 +10,10 @@ use Chill\MainBundle\Search\HasAdvancedSearchFormInterface; * installed into the app. * the service is callable from the container with * $container->get('chill_main.search_provider') - * - * the syntax for search string is : - * - domain, which begin with `@`. Example: `@person`. Restrict the search to some - * entities. It may exists multiple search provider for the same domain (example: + * + * the syntax for search string is : + * - domain, which begin with `@`. Example: `@person`. Restrict the search to some + * entities. It may exists multiple search provider for the same domain (example: * a search provider for people which performs regular search, and suggestion search * with phonetical algorithms * - terms, which are the terms of the search. There are terms with argument (example : @@ -25,17 +25,17 @@ class SearchProvider { /** - * + * * @var SearchInterface[] */ private $searchServices = array(); - + /** * * @var HasAdvancedSearchForm[] */ private $hasAdvancedFormSearchServices = array(); - + /* * return search services in an array, ordered by * the order key (defined in service definition) @@ -59,7 +59,7 @@ class SearchProvider return $this->searchServices; } - + public function getHasAdvancedFormSearchServices() { //sort the array @@ -75,7 +75,7 @@ class SearchProvider /** * parse the search string to extract domain and terms - * + * * @param string $pattern * @return string[] an array where the keys are _domain, _default (residual terms) or term */ @@ -95,9 +95,9 @@ class SearchProvider /** * Extract the domain of the subject - * + * * The domain begins with `@`. Example: `@person`, `@report`, .... - * + * * @param type $subject * @return string * @throws ParsingException @@ -121,14 +121,15 @@ class SearchProvider private function extractTerms(&$subject) { $terms = array(); - preg_match_all('/([a-z\-]+):([\w\-]+|\([^\(\r\n]+\))/', $subject, $matches); + $matches = []; + preg_match_all('/([a-z\-]+):(([^"][\S\-]+)|"[^"]*")/', $subject, $matches); foreach ($matches[2] as $key => $match) { //remove from search pattern $this->mustBeExtracted[] = $matches[0][$key]; //strip parenthesis - if (mb_substr($match, 0, 1) === '(' && - mb_substr($match, mb_strlen($match) - 1) === ')') { + if (mb_substr($match, 0, 1) === '"' && + mb_substr($match, mb_strlen($match) - 1) === '"') { $match = trim(mb_substr($match, 1, mb_strlen($match) - 2)); } $terms[$matches[1][$key]] = $match; @@ -139,14 +140,14 @@ class SearchProvider /** * store string which must be extracted to find default arguments - * + * * @var string[] */ private $mustBeExtracted = array(); /** * extract default (residual) arguments - * + * * @param string $subject * @return string */ @@ -158,7 +159,7 @@ class SearchProvider /** * search through services which supports domain and give * results as an array of resultsfrom different SearchInterface - * + * * @param string $pattern * @param number $start * @param number $limit @@ -167,25 +168,25 @@ class SearchProvider * @return array of results from different SearchInterface * @throws UnknowSearchDomainException if the domain is unknow */ - public function getSearchResults($pattern, $start = 0, $limit = 50, + public function getSearchResults($pattern, $start = 0, $limit = 50, array $options = array(), $format = 'html') { $terms = $this->parse($pattern); $results = array(); - + //sort searchServices by order $sortedSearchServices = array(); foreach($this->searchServices as $service) { $sortedSearchServices[$service->getOrder()] = $service; } - + if ($terms['_domain'] !== NULL) { foreach ($sortedSearchServices as $service) { if ($service->supports($terms['_domain'], $format)) { $results[] = $service->renderResult($terms, $start, $limit, $options); } } - + if (count($results) === 0) { throw new UnknowSearchDomainException($terms['_domain']); } @@ -196,24 +197,24 @@ class SearchProvider } } } - + //sort array ksort($results); return $results; } - + public function getResultByName($pattern, $name, $start = 0, $limit = 50, - array $options = array(), $format = 'html') + array $options = array(), $format = 'html') { $terms = $this->parse($pattern); $search = $this->getByName($name); - + if ($terms['_domain'] !== NULL && !$search->supports($terms['_domain'], $format)) { throw new ParsingException("The domain is not supported for the search name"); } - + return $search->renderResult($terms, $start, $limit, $options, $format); } @@ -232,16 +233,16 @@ class SearchProvider throw new UnknowSearchNameException($name); } } - + /** - * return searchservice with an advanced form, defined in service + * return searchservice with an advanced form, defined in service * definition. - * + * * @param string $name * @return HasAdvancedSearchForm * @throws UnknowSearchNameException */ - public function getHasAdvancedFormByName($name) + public function getHasAdvancedFormByName($name) { if (\array_key_exists($name, $this->hasAdvancedFormSearchServices)) { return $this->hasAdvancedFormSearchServices[$name]; @@ -253,7 +254,7 @@ class SearchProvider public function addSearchService(SearchInterface $service, $name) { $this->searchServices[$name] = $service; - + if ($service instanceof HasAdvancedSearchFormInterface) { $this->hasAdvancedFormSearchServices[$name] = $service; } @@ -477,7 +478,7 @@ class SearchProvider $string = strtr($string, $chars); } /* remove from wordpress: we use only utf 8 * else { - + // Assume ISO-8859-1 if not UTF-8 $chars['in'] = chr(128) . chr(131) . chr(138) . chr(142) . chr(154) . chr(158) . chr(159) . chr(162) . chr(165) . chr(181) . chr(192) . chr(193) . chr(194) diff --git a/src/Bundle/ChillMainBundle/Search/Utils/ExtractDateFromPattern.php b/src/Bundle/ChillMainBundle/Search/Utils/ExtractDateFromPattern.php new file mode 100644 index 000000000..84540a708 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Search/Utils/ExtractDateFromPattern.php @@ -0,0 +1,36 @@ + ""])); + } + } + } + + return new SearchExtractionResult($filteredSubject, $dates); + } +} diff --git a/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php b/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php new file mode 100644 index 000000000..b4601a5f7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php @@ -0,0 +1,54 @@ + $char) { + switch ($char) { + case '0': + $length++; + if ($key === 0) { $phonenumber[] = '+32'; } + else { $phonenumber[] = $char; } + break; + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + $length++; + $phonenumber[] = $char; + break; + case ' ': + break; + default: + throw new \LogicException("should not match not alnum character"); + } + } + + if ($length > 5) { + $filtered = \trim(\strtr($subject, [$matches[0] => ''])); + + return new SearchExtractionResult($filtered, [\implode('', $phonenumber)] ); + } + } + + return new SearchExtractionResult($subject, []); + } +} diff --git a/src/Bundle/ChillMainBundle/Search/Utils/SearchExtractionResult.php b/src/Bundle/ChillMainBundle/Search/Utils/SearchExtractionResult.php new file mode 100644 index 000000000..e9ca2a691 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Search/Utils/SearchExtractionResult.php @@ -0,0 +1,30 @@ +filteredSubject = $filteredSubject; + $this->found = $found; + } + + public function getFound(): array + { + return $this->found; + } + + public function hasResult(): bool + { + return [] !== $this->found; + } + + public function getFilteredSubject(): string + { + return $this->filteredSubject; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Search/SearchApiQueryTest.php b/src/Bundle/ChillMainBundle/Tests/Search/SearchApiQueryTest.php index 6aeb835e7..8e05f528f 100644 --- a/src/Bundle/ChillMainBundle/Tests/Search/SearchApiQueryTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Search/SearchApiQueryTest.php @@ -12,7 +12,7 @@ class SearchApiQueryTest extends TestCase $q = new SearchApiQuery(); $q->setSelectJsonbMetadata('boum') ->setSelectKey('bim') - ->setSelectPertinence('1') + ->setSelectPertinence('?', ['gamma']) ->setFromClause('badaboum') ->andWhereClause('foo', [ 'alpha' ]) ->andWhereClause('bar', [ 'beta' ]) @@ -21,12 +21,12 @@ class SearchApiQueryTest extends TestCase $query = $q->buildQuery(); $this->assertStringContainsString('(foo) AND (bar)', $query); - $this->assertEquals(['alpha', 'beta'], $q->buildParameters()); + $this->assertEquals(['gamma', 'alpha', 'beta'], $q->buildParameters()); $query = $q->buildQuery(true); $this->assertStringContainsString('(foo) AND (bar)', $query); - $this->assertEquals(['alpha', 'beta'], $q->buildParameters()); + $this->assertEquals(['gamma', 'alpha', 'beta'], $q->buildParameters()); } public function testWithoutWhereClause() @@ -42,4 +42,20 @@ class SearchApiQueryTest extends TestCase $this->assertEquals([], $q->buildParameters()); } + public function testBuildParams() + { + $q = new SearchApiQuery(); + + $q + ->addSelectClause('bada', [ 'one', 'two' ]) + ->addSelectClause('boum', ['three', 'four']) + ->setWhereClauses('mywhere', [ 'six', 'seven']) + ; + + $params = $q->buildParameters(); + + $this->assertEquals(['six', 'seven'], $q->buildParameters(true)); + $this->assertEquals(['one', 'two', 'three', 'four', 'six', 'seven'], $q->buildParameters()); + } + } diff --git a/src/Bundle/ChillMainBundle/Tests/Search/SearchProviderTest.php b/src/Bundle/ChillMainBundle/Tests/Search/SearchProviderTest.php index 554b67b9a..99e5baed3 100644 --- a/src/Bundle/ChillMainBundle/Tests/Search/SearchProviderTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Search/SearchProviderTest.php @@ -26,17 +26,17 @@ use PHPUnit\Framework\TestCase; class SearchProviderTest extends TestCase { - + /** * - * @var SearchProvider + * @var SearchProvider */ private $search; - + public function setUp() { $this->search = new SearchProvider(); - + //add a default service $this->addSearchService( $this->createDefaultSearchService('I am default', 10), 'default' @@ -46,7 +46,7 @@ class SearchProviderTest extends TestCase $this->createNonDefaultDomainSearchService('I am domain bar', 20, FALSE), 'bar' ); } - + /** * @expectedException \Chill\MainBundle\Search\UnknowSearchNameException */ @@ -54,11 +54,11 @@ class SearchProviderTest extends TestCase { $this->search->getByName("invalid name"); } - + public function testSimplePattern() { - $terms = $this->p("@person birthdate:2014-01-02 name:(my name) is not my name"); - + $terms = $this->p("@person birthdate:2014-01-02 name:\"my name\" is not my name"); + $this->assertEquals(array( '_domain' => 'person', 'birthdate' => '2014-01-02', @@ -66,40 +66,40 @@ class SearchProviderTest extends TestCase 'name' => 'my name' ), $terms); } - + public function testWithoutDomain() { $terms = $this->p('foo:bar residual'); - + $this->assertEquals(array( '_domain' => null, 'foo' => 'bar', '_default' => 'residual' ), $terms); } - + public function testWithoutDefault() { $terms = $this->p('@person foo:bar'); - + $this->assertEquals(array( '_domain' => 'person', 'foo' => 'bar', '_default' => '' ), $terms); } - + public function testCapitalLetters() { $terms = $this->p('Foo:Bar LOL marCi @PERSON'); - + $this->assertEquals(array( '_domain' => 'person', '_default' => 'lol marci', 'foo' => 'bar' ), $terms); } - + /** * @expectedException Chill\MainBundle\Search\ParsingException */ @@ -107,12 +107,11 @@ class SearchProviderTest extends TestCase { $term = $this->p("@person @report"); } - + public function testDoubleParenthesis() { - $terms = $this->p("@papamobile name:(my beautiful name) residual " - . "surname:(i love techno)"); - + $terms = $this->p('@papamobile name:"my beautiful name" residual surname:"i love techno"'); + $this->assertEquals(array( '_domain' => 'papamobile', 'name' => 'my beautiful name', @@ -120,65 +119,65 @@ class SearchProviderTest extends TestCase 'surname' => 'i love techno' ), $terms); } - + public function testAccentued() { //$this->markTestSkipped('accentued characters must be implemented'); - + $terms = $this->p('manço bélier aztèque à saloù ê'); - + $this->assertEquals(array( '_domain' => NULL, '_default' => 'manco belier azteque a salou e' ), $terms); } - + public function testAccentuedCapitals() { //$this->markTestSkipped('accentued characters must be implemented'); - + $terms = $this->p('MANÉÀ oÛ lÎ À'); - + $this->assertEquals(array( '_domain' => null, '_default' => 'manea ou li a' ), $terms); } - + public function testTrimInParenthesis() { - $terms = $this->p('foo:(bar )'); - + $terms = $this->p('foo:"bar "'); + $this->assertEquals(array( '_domain' => null, 'foo' => 'bar', '_default' => '' ), $terms); } - + public function testTrimInDefault() { $terms = $this->p(' foo bar '); - + $this->assertEquals(array( '_domain' => null, '_default' => 'foo bar' ), $terms); } - + public function testArgumentNameWithTrait() { $terms = $this->p('date-from:2016-05-04'); - + $this->assertEquals(array( '_domain' => null, 'date-from' => '2016-05-04', '_default' => '' ), $terms); } - + /** - * Test the behaviour when no domain is provided in the search pattern : + * Test the behaviour when no domain is provided in the search pattern : * the default search should be enabled */ public function testSearchResultDefault() @@ -186,12 +185,12 @@ class SearchProviderTest extends TestCase $response = $this->search->getSearchResults('default search'); //$this->markTestSkipped(); - + $this->assertEquals(array( "I am default" - ), $response); + ), $response); } - + /** * @expectedException \Chill\MainBundle\Search\UnknowSearchDomainException */ @@ -200,49 +199,49 @@ class SearchProviderTest extends TestCase $response = $this->search->getSearchResults('@unknow domain'); //$this->markTestSkipped(); - + } - + public function testSearchResultDomainSearch() { //add a search service which will be supported $this->addSearchService( $this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo' ); - + $response = $this->search->getSearchResults('@foo default search'); - + $this->assertEquals(array( "I am domain foo" ), $response); - + } - + public function testSearchWithinSpecificSearchName() { //add a search service which will be supported $this->addSearchService( $this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo' ); - + $response = $this->search->getResultByName('@foo search', 'foo'); - + $this->assertEquals('I am domain foo', $response); - + } - + /** * @expectedException \Chill\MainBundle\Search\ParsingException */ public function testSearchWithinSpecificSearchNameInConflictWithSupport() { $response = $this->search->getResultByName('@foo default search', 'bar'); - + } - + /** * shortcut for executing parse method - * + * * @param unknown $pattern * @return string[] */ @@ -250,12 +249,12 @@ class SearchProviderTest extends TestCase { return $this->search->parse($pattern); } - + /** * Add a search service to the chill.main.search_provider - * + * * Useful for mocking the SearchInterface - * + * * @param SearchInterface $search * @param string $name */ @@ -264,52 +263,52 @@ class SearchProviderTest extends TestCase $this->search ->addSearchService($search, $name); } - + private function createDefaultSearchService($result, $order) { $mock = $this ->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch'); - + //set the mock as default $mock->expects($this->any()) ->method('isActiveByDefault') ->will($this->returnValue(TRUE)); - + $mock->expects($this->any()) ->method('getOrder') ->will($this->returnValue($order)); - + //set the return value $mock->expects($this->any()) ->method('renderResult') ->will($this->returnValue($result)); - + return $mock; } - + private function createNonDefaultDomainSearchService($result, $order, $domain) { $mock = $this ->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch'); - + //set the mock as default $mock->expects($this->any()) ->method('isActiveByDefault') ->will($this->returnValue(FALSE)); - + $mock->expects($this->any()) ->method('getOrder') ->will($this->returnValue($order)); - + $mock->expects($this->any()) ->method('supports') ->will($this->returnValue($domain)); - + //set the return value $mock->expects($this->any()) ->method('renderResult') ->will($this->returnValue($result)); - + return $mock; } } diff --git a/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractDateFromPatternTest.php b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractDateFromPatternTest.php new file mode 100644 index 000000000..428996b8e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractDateFromPatternTest.php @@ -0,0 +1,45 @@ +extractDates($subject); + + $this->assertCount($count, $result->getFound()); + $this->assertEquals($filtered, $result->getFilteredSubject()); + $this->assertContainsOnlyInstancesOf(\DateTimeImmutable::class, $result->getFound()); + + $dates = \array_map( + function (\DateTimeImmutable $d) { + return $d->format('Y-m-d'); + }, $result->getFound() + ); + + foreach ($datesSearched as $date) { + $this->assertContains($date, $dates); + } + } + + public function provideSubjects() + { + yield ["15/06/1981", "", 1, '1981-06-15']; + yield ["15/06/1981 30/12/1987", "", 2, '1981-06-15', '1987-12-30']; + yield ["diallo 15/06/1981", "diallo", 1, '1981-06-15']; + yield ["diallo 31/03/1981", "diallo", 1, '1981-03-31']; + yield ["diallo 15-06-1981", "diallo", 1, '1981-06-15']; + yield ["diallo 1981-12-08", "diallo", 1, '1981-12-08']; + yield ["diallo", "diallo", 0]; + } + +} diff --git a/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php new file mode 100644 index 000000000..5c88efa7e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php @@ -0,0 +1,33 @@ +extractPhonenumber($subject); + + $this->assertCount($expectedCount, $result->getFound()); + $this->assertEquals($filteredSubject, $result->getFilteredSubject()); + $this->assertEquals($expected, $result->getFound()); + } + + public function provideData() + { + yield ['Diallo', 0, [], 'Diallo', "no phonenumber"]; + yield ['Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', "no phonenumber and a date"]; + yield ['Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo', "a phonenumber and a name"]; + yield ['Diallo 123 456', 1, ['123456'], 'Diallo', "a number and a name, without leadiing 0"]; + yield ['123 456', 1, ['123456'], '', "only phonenumber"]; + yield ['0123 456', 1, ['+32123456'], '', "only phonenumber with a leading 0"]; + } + +} diff --git a/src/Bundle/ChillMainBundle/config/services/search.yaml b/src/Bundle/ChillMainBundle/config/services/search.yaml index 377b0655a..91cfb120b 100644 --- a/src/Bundle/ChillMainBundle/config/services/search.yaml +++ b/src/Bundle/ChillMainBundle/config/services/search.yaml @@ -8,7 +8,16 @@ services: Chill\MainBundle\Search\SearchProvider: '@chill_main.search_provider' - Chill\MainBundle\Search\SearchApi: ~ + Chill\MainBundle\Search\SearchApi: + autowire: true + autoconfigure: true + arguments: + $providers: !tagged_iterator chill.search_api_provider Chill\MainBundle\Search\Entity\: resource: '../../Search/Entity' + + Chill\MainBundle\Search\Utils\: + autowire: true + autoconfigure: true + resource: './../Search/Utils/' diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211119173554.php b/src/Bundle/ChillMainBundle/migrations/Version20211119173554.php new file mode 100644 index 000000000..d8f705f39 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211119173554.php @@ -0,0 +1,35 @@ +addSql("COMMENT ON COLUMN $col IS NULL"); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException(); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 598edaec6..9b9ceb204 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -86,6 +86,10 @@ address more: Create a new address: Créer une nouvelle adresse Create an address: Créer une adresse Update address: Modifier l'adresse +City or postal code: Ville ou code postal + +# contact +Part of the phonenumber: Partie du numéro de téléphone #serach Your search is empty. Please provide search terms.: La recherche est vide. Merci de fournir des termes de recherche. @@ -190,7 +194,7 @@ Location: Localisation Location type list: Liste des types de localisation Create a new location type: Créer un nouveau type de localisation Available for users: Disponible aux utilisateurs -Address required: Adresse requise? +Address required: Adresse requise? Contact data: Données de contact? optional: optionnel required: requis diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 211765330..ce4169741 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -86,13 +86,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac $loader->load('services/security.yaml'); $loader->load('services/doctrineEventListener.yaml'); - // load service advanced search only if configure - if ($config['search']['search_by_phone'] != 'never') { - $loader->load('services/search_by_phone.yaml'); - $container->setParameter('chill_person.search.search_by_phone', - $config['search']['search_by_phone']); - } - if ($container->getParameter('chill_person.accompanying_period') !== 'hidden') { $loader->load('services/exports_accompanying_period.yaml'); } diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php index 2c53af0fe..4e42e3137 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php @@ -27,19 +27,6 @@ class Configuration implements ConfigurationInterface $rootNode ->canBeDisabled() ->children() - ->arrayNode('search') - ->canBeDisabled() - ->children() - ->enumNode('search_by_phone') - ->values(['always', 'on-domain', 'never']) - ->defaultValue('on-domain') - ->info('enable search by phone. \'always\' show the result ' - . 'on every result. \'on-domain\' will show the result ' - . 'only if the domain is given in the search box. ' - . '\'never\' disable this feature') - ->end() - ->end() //children for 'search', parent = array node 'search' - ->end() // array 'search', parent = children of root ->arrayNode('validation') ->canBeDisabled() ->children() diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index 7ff90b652..cc497aeca 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -434,6 +434,5 @@ class Household ->addViolation(); } } - dump($cond); } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 4e8e1d0e2..dcb70510b 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -39,10 +39,16 @@ use DateTimeInterface; * * @ORM\Entity * @ORM\Table(name="chill_person_person", - * indexes={@ORM\Index( + * indexes={ + * @ORM\Index( * name="person_names", * columns={"firstName", "lastName"} - * )}) + * ), + * @ORM\Index( + * name="person_birthdate", + * columns={"birthdate"} + * ) + * }) * @ORM\HasLifecycleCallbacks() * @DiscriminatorMap(typeProperty="type", mapping={ * "person"=Person::class diff --git a/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php b/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php index 222495e17..c4497fce8 100644 --- a/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php +++ b/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php @@ -9,7 +9,10 @@ use Doctrine\ORM\Mapping as ORM; * Person Phones * * @ORM\Entity - * @ORM\Table(name="chill_person_phone") + * @ORM\Table(name="chill_person_phone", + * indexes={ + * @ORM\Index(name="phonenumber", columns={"phonenumber"}) + * }) */ class PersonPhone { @@ -107,7 +110,7 @@ class PersonPhone { $this->date = $date; } - + public function isEmpty(): bool { return empty($this->getDescription()) && empty($this->getPhonenumber()); diff --git a/src/Bundle/ChillPersonBundle/Form/Type/GenderType.php b/src/Bundle/ChillPersonBundle/Form/Type/GenderType.php index bdd31e899..5cad3b4f1 100644 --- a/src/Bundle/ChillPersonBundle/Form/Type/GenderType.php +++ b/src/Bundle/ChillPersonBundle/Form/Type/GenderType.php @@ -31,7 +31,7 @@ class GenderType extends AbstractType { 'choices' => $a, 'expanded' => true, 'multiple' => false, - 'placeholder' => null + 'placeholder' => null, )); } diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php index cd4f8c9ce..af8ed1a12 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php @@ -2,11 +2,15 @@ namespace Chill\PersonBundle\Repository; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Search\ParsingException; +use Chill\MainBundle\Search\SearchApi; +use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Security\Authorization\PersonVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; @@ -49,125 +53,114 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac string $default = null, string $firstname = null, string $lastname = null, - ?\DateTime $birthdate = null, - ?\DateTime $birthdateBefore = null, - ?\DateTime $birthdateAfter = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, string $gender = null, - string $countryCode = null + string $countryCode = null, + string $phonenumber = null, + string $city = null ): array { - $qb = $this->createSearchQuery($default, $firstname, $lastname, + $query = $this->buildAuthorizedQuery($default, $firstname, $lastname, $birthdate, $birthdateBefore, $birthdateAfter, $gender, - $countryCode); - $this->addACLClauses($qb, 'p'); + $countryCode, $phonenumber, $city); - return $this->getQueryResult($qb, 'p', $simplify, $limit, $start); - } - - /** - * Helper method to prepare and return the search query for PersonACL. - * - * This method replace the select clause with required parameters, depending on the - * "simplify" parameter. It also add query limits. - * - * The given alias must represent the person alias. - * - * @return array|Person[] - */ - public function getQueryResult(QueryBuilder $qb, string $alias, bool $simplify, int $limit, int $start): array - { - if ($simplify) { - $qb->select( - $alias.'.id', - $qb->expr()->concat( - $alias.'.firstName', - $qb->expr()->literal(' '), - $alias.'.lastName' - ).'AS text' - ); - } else { - $qb->select($alias); - } - - $qb - ->setMaxResults($limit) - ->setFirstResult($start); - - //order by firstname, lastname - $qb - ->orderBy($alias.'.firstName') - ->addOrderBy($alias.'.lastName'); - - if ($simplify) { - return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY); - } else { - return $qb->getQuery()->getResult(); - } + return $this->fetchQueryPerson($query); } public function countBySearchCriteria( string $default = null, string $firstname = null, string $lastname = null, - ?\DateTime $birthdate = null, - ?\DateTime $birthdateBefore = null, - ?\DateTime $birthdateAfter = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, string $gender = null, - string $countryCode = null + string $countryCode = null, + string $phonenumber = null, + string $city = null ): int { - $qb = $this->createSearchQuery($default, $firstname, $lastname, + $query = $this->buildAuthorizedQuery($default, $firstname, $lastname, $birthdate, $birthdateBefore, $birthdateAfter, $gender, - $countryCode); - $this->addACLClauses($qb, 'p'); + $countryCode, $phonenumber, $city) + ; - return $this->getCountQueryResult($qb,'p'); + return $this->fetchQueryCount($query); + } + + public function fetchQueryCount(SearchApiQuery $query): int + { + $rsm = new Query\ResultSetMapping(); + $rsm->addScalarResult('c', 'c'); + + $nql = $this->em->createNativeQuery($query->buildQuery(true), $rsm); + $nql->setParameters($query->buildParameters(true)); + + return $nql->getSingleScalarResult(); } /** - * Helper method to prepare and return the count for search query - * - * This method replace the select clause with required parameters, depending on the - * "simplify" parameter. - * - * The given alias must represent the person alias in the query builder. + * @return array|Person[] */ - public function getCountQueryResult(QueryBuilder $qb, $alias): int + public function fetchQueryPerson(SearchApiQuery $query, ?int $start = 0, ?int $limit = 50): array { - $qb->select('COUNT('.$alias.'.id)'); + $rsm = new Query\ResultSetMappingBuilder($this->em); + $rsm->addRootEntityFromClassMetadata(Person::class, 'person'); - return $qb->getQuery()->getSingleScalarResult(); + $query->addSelectClause($rsm->generateSelectClause()); + + $nql = $this->em->createNativeQuery( + $query->buildQuery()." ORDER BY pertinence DESC OFFSET ? LIMIT ?", $rsm + )->setParameters(\array_merge($query->buildParameters(), [$start, $limit])); + + return $nql->getResult(); } - public function findBySimilaritySearch(string $pattern, int $firstResult, - int $maxResult, bool $simplify = false) - { - $qb = $this->createSimilarityQuery($pattern); - $this->addACLClauses($qb, 'sp'); + public function buildAuthorizedQuery( + string $default = null, + string $firstname = null, + string $lastname = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, + string $gender = null, + string $countryCode = null, + string $phonenumber = null, + string $city = null + ): SearchApiQuery { + $query = $this->createSearchQuery($default, $firstname, $lastname, + $birthdate, $birthdateBefore, $birthdateAfter, $gender, + $countryCode, $phonenumber) + ; - return $this->getQueryResult($qb, 'sp', $simplify, $maxResult, $firstResult); + return $this->addAuthorizations($query); } - public function countBySimilaritySearch(string $pattern) + private function addAuthorizations(SearchApiQuery $query): SearchApiQuery { - $qb = $this->createSimilarityQuery($pattern); - $this->addACLClauses($qb, 'sp'); + $authorizedCenters = $this->authorizationHelper + ->getReachableCenters($this->security->getUser(), PersonVoter::SEE); - return $this->getCountQueryResult($qb, 'sp'); + if ([] === $authorizedCenters) { + return $query->andWhereClause("FALSE = TRUE", []); + } + + return $query + ->andWhereClause( + strtr( + "person.center_id IN ({{ center_ids }})", + [ + '{{ center_ids }}' => \implode(', ', + \array_fill(0, count($authorizedCenters), '?')), + ] + ), + \array_map(function(Center $c) {return $c->getId();}, $authorizedCenters) + ); } /** * Create a search query without ACL * - * The person alias is a "p" - * - * @param string|null $default - * @param string|null $firstname - * @param string|null $lastname - * @param \DateTime|null $birthdate - * @param \DateTime|null $birthdateBefore - * @param \DateTime|null $birthdateAfter - * @param string|null $gender - * @param string|null $countryCode - * @return QueryBuilder * @throws NonUniqueResultException * @throws ParsingException */ @@ -175,118 +168,107 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac string $default = null, string $firstname = null, string $lastname = null, - ?\DateTime $birthdate = null, - ?\DateTime $birthdateBefore = null, - ?\DateTime $birthdateAfter = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, string $gender = null, - string $countryCode = null - ): QueryBuilder { + string $countryCode = null, + string $phonenumber = null, + string $city = null + ): SearchApiQuery { + $query = new SearchApiQuery(); + $query + ->setFromClause("chill_person_person AS person") + ; - if (!$this->security->getUser() instanceof User) { - throw new \RuntimeException("Search must be performed by a valid user"); - } - $qb = $this->em->createQueryBuilder(); - $qb->from(Person::class, 'p'); + $pertinence = []; + $pertinenceArgs = []; + $orWhereSearchClause = []; + $orWhereSearchClauseArgs = []; - if (NULL !== $firstname) { - $qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname')) - ->setParameter('firstname', '%'.$firstname.'%'); - } + if ("" !== $default) { + foreach (\explode(" ", $default) as $str) { + $pertinence[] = + "STRICT_WORD_SIMILARITY(LOWER(UNACCENT(?)), person.fullnamecanonical) + ". + "(person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%')::int + ". + "(EXISTS (SELECT 1 FROM unnest(string_to_array(fullnamecanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int"; + \array_push($pertinenceArgs, $str, $str, $str); - if (NULL !== $lastname) { - $qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname')) - ->setParameter('lastname', '%'.$lastname.'%'); + $orWhereSearchClause[] = + "(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ". + "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )"; + \array_push($orWhereSearchClauseArgs, $str, $str); + } + + $query->andWhereClause(\implode(' OR ', $orWhereSearchClause), + $orWhereSearchClauseArgs); + } else { + $pertinence = ["1"]; + $pertinenceArgs = []; } + $query + ->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs) + ; if (NULL !== $birthdate) { - $qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate')) - ->setParameter('birthdate', $birthdate); + $query->andWhereClause( + "person.birthdate = ?::date", + [$birthdate->format('Y-m-d')] + ); } - - if (NULL !== $birthdateAfter) { - $qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter')) - ->setParameter('birthdateafter', $birthdateAfter); + if (NULL !== $firstname) { + $query->andWhereClause( + "UNACCENT(LOWER(person.firstname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'", + [$firstname] + ); + } + if (NULL !== $lastname) { + $query->andWhereClause( + "UNACCENT(LOWER(person.lastname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'", + [$lastname] + ); } - if (NULL !== $birthdateBefore) { - $qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore')) - ->setParameter('birthdatebefore', $birthdateBefore); + $query->andWhereClause( + 'p.birthdate < ?::date', + [$birthdateBefore->format('Y-m-d')] + ); } - - if (NULL !== $gender) { - $qb->andWhere($qb->expr()->eq('p.gender', ':gender')) - ->setParameter('gender', $gender); + if (NULL !== $birthdateAfter) { + $query->andWhereClause( + 'p.birthdate > ?::date', + [$birthdateAfter->format('Y-m-d')] + ); } - - if (NULL !== $countryCode) { - try { - $country = $this->countryRepository->findOneBy(['countryCode' => $countryCode]); - } catch (NoResultException $ex) { - throw new ParsingException('The country code "'.$countryCode.'" ' - . ', used in nationality, is unknow', 0, $ex); - } catch (NonUniqueResultException $e) { - throw $e; - } - - $qb->andWhere($qb->expr()->eq('p.nationality', ':nationality')) - ->setParameter('nationality', $country); + if (NULL !== $phonenumber) { + $query->andWhereClause( + "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'" + , + [$phonenumber, $phonenumber, $phonenumber] + ); + $query->setFromClause($query->getFromClause()." LEFT JOIN chill_person_phone pp ON pp.person_id = person.id"); } + if (null !== $city) { + $query->setFromClause($query->getFromClause()." ". + "JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id ". + "JOIN chill_main_address cma ON vcpca.address_id = cma.id ". + "JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id"); - if (NULL !== $default) { - $grams = explode(' ', $default); - - foreach($grams as $key => $gram) { - $qb->andWhere($qb->expr() - ->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))')) - ->setParameter('default_'.$key, '%'.$gram.'%'); + foreach (\explode(" ", $city) as $cityStr) { + $query->andWhereClause( + "(UNACCENT(LOWER(cmpc.label)) LIKE '%' || UNACCENT(LOWER(?)) || '%' OR cmpc.code LIKE '%' || UNACCENT(LOWER(?)) || '%')", + [$cityStr, $city] + ); } } - - return $qb; - } - - private function addACLClauses(QueryBuilder $qb, string $personAlias): void - { - // restrict center for security - $reachableCenters = $this->authorizationHelper - ->getReachableCenters($this->security->getUser(), 'CHILL_PERSON_SEE'); - $qb->andWhere( - $qb->expr()->orX( - $qb->expr() - ->in($personAlias.'.center', ':centers'), - $qb->expr() - ->isNull($personAlias.'.center') - ) - ); - $qb->setParameter('centers', $reachableCenters); - } - - /** - * Create a query for searching by similarity. - * - * The person alias is "sp". - * - * @param $pattern - * @return QueryBuilder - */ - public function createSimilarityQuery($pattern): QueryBuilder - { - $qb = $this->em->createQueryBuilder(); - - $qb->from(Person::class, 'sp'); - - $grams = explode(' ', $pattern); - - foreach($grams as $key => $gram) { - $qb->andWhere('STRICT_WORD_SIMILARITY_OPS(:default_'.$key.', sp.fullnameCanonical) = TRUE') - ->setParameter('default_'.$key, '%'.$gram.'%'); - - // remove the perfect matches - $qb->andWhere($qb->expr() - ->notLike('sp.fullnameCanonical', 'UNACCENT(LOWER(:not_default_'.$key.'))')) - ->setParameter('not_default_'.$key, '%'.$gram.'%'); + if (null !== $countryCode) { + $query->setFromClause($query->getFromClause()." JOIN country ON person.nationality_id = country.id"); + $query->andWhereClause("country.countrycode = UPPER(?)", [$countryCode]); + } + if (null !== $gender) { + $query->andWhereClause("person.gender = ?", [$gender]); } - return $qb; + return $query; } } diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php index 8e83da03d..89566b5a6 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php @@ -3,16 +3,14 @@ namespace Chill\PersonBundle\Repository; use Chill\MainBundle\Search\ParsingException; +use Chill\MainBundle\Search\SearchApiQuery; use Chill\PersonBundle\Entity\Person; use Doctrine\ORM\NonUniqueResultException; interface PersonACLAwareRepositoryInterface { - /** * @return array|Person[] - * @throws NonUniqueResultException - * @throws ParsingException */ public function findBySearchCriteria( int $start, @@ -21,30 +19,38 @@ interface PersonACLAwareRepositoryInterface string $default = null, string $firstname = null, string $lastname = null, - ?\DateTime $birthdate = null, - ?\DateTime $birthdateBefore = null, - ?\DateTime $birthdateAfter = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, string $gender = null, - string $countryCode = null + string $countryCode = null, + string $phonenumber = null, + string $city = null ): array; public function countBySearchCriteria( string $default = null, string $firstname = null, string $lastname = null, - ?\DateTime $birthdate = null, - ?\DateTime $birthdateBefore = null, - ?\DateTime $birthdateAfter = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, string $gender = null, - string $countryCode = null + string $countryCode = null, + string $phonenumber = null, + string $city = null ); - public function findBySimilaritySearch( - string $pattern, - int $firstResult, - int $maxResult, - bool $simplify = false - ); - - public function countBySimilaritySearch(string $pattern); + public function buildAuthorizedQuery( + string $default = null, + string $firstname = null, + string $lastname = null, + ?\DateTimeInterface $birthdate = null, + ?\DateTimeInterface $birthdateBefore = null, + ?\DateTimeInterface $birthdateAfter = null, + string $gender = null, + string $countryCode = null, + string $phonenumber = null, + string $city = null + ): SearchApiQuery; } diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig index 98dff821a..f28115d8d 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig @@ -1,7 +1,12 @@ -{% macro button_person(person) %} +{% macro button_person_after(person) %} + {% set household = person.getCurrentHousehold %} + {% if household is not null %} +
  • + +
  • + {% endif %}
  • - +
  • {% endmacro %} @@ -56,7 +61,7 @@ 'addAltNames': true, 'addCenter': true, 'address_multiline': false, - 'customButtons': { 'after': _self.button_person(person) } + 'customButtons': { 'after': _self.button_person_after(person) } }) }} {#- 'acps' is for AcCompanyingPeriodS #} @@ -76,12 +81,20 @@
    -
    - {% if acp.requestorPerson == person %} - + {% if acp.step == 'DRAFT' %} +
    + {{ 'course.draft'|trans }} +
    + {% endif %} + {% if acp.requestorPerson == person %} +
    + {{ 'Requestor'|trans({'gender': person.gender}) }} - - {% endif %} + +
    + {% endif %} + +
    {% if app != null %} {{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }} {% endif %} @@ -94,6 +107,11 @@
    {% endif %} +
    + {{ 'File number'|trans }} {{ acp.id }} +
    + +
    @@ -101,17 +119,76 @@ {{ issue|chill_entity_render_box }} {% endfor %} -
      +
      • +
      • +
    + {% if acp.currentParticipations|length > 1 %} +
    +
    +
    + {{ 'Participants'|trans }} +
    +
    +
    + {% set participating = false %} + {% for part in acp.currentParticipations %} + {% if part.person.id != person.id %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + targetEntity: { name: 'person', id: part.person.id }, + action: 'show', + displayBadge: true, + buttonText: part.person|chill_entity_render_string + } %} + {% else %} + {% set participating = true %} + {% endif %} + {% endfor %} + {% if participating %} + {{ 'person.and_himself'|trans({'gender': person.gender}) }} + {% endif %} +
    +
    + {% endif %} + {% if (acp.requestorPerson is not null and acp.requestorPerson.id != person.id) or acp.requestorThirdParty is not null %} +
    +
    +
    + {% if acp.requestorPerson is not null %} + {{ 'Requestor'|trans({'gender': acp.requestorPerson.gender}) }} + {% else %} + {{ 'Requestor'|trans({'gender': 'other'})}} + {% endif %} +
    +
    +
    + {% if acp.requestorThirdParty is not null %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + targetEntity: { name: 'thirdparty', id: acp.requestorThirdParty.id }, + action: 'show', + displayBadge: true, + buttonText: acp.requestorThirdParty|chill_entity_render_string + } %} + {% else %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + targetEntity: { name: 'person', id: acp.requestorPerson.id }, + action: 'show', + displayBadge: true, + buttonText: acp.requestorPerson|chill_entity_render_string + } %} + {% endif %} +
    +
    + {% endif %} {% endfor %}
    diff --git a/src/Bundle/ChillPersonBundle/Search/PersonSearch.php b/src/Bundle/ChillPersonBundle/Search/PersonSearch.php index d83749147..f4a6c19bf 100644 --- a/src/Bundle/ChillPersonBundle/Search/PersonSearch.php +++ b/src/Bundle/ChillPersonBundle/Search/PersonSearch.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace Chill\PersonBundle\Search; use Chill\MainBundle\Search\AbstractSearch; +use Chill\MainBundle\Search\Utils\ExtractDateFromPattern; +use Chill\MainBundle\Search\Utils\ExtractPhonenumberFromPattern; +use Chill\PersonBundle\Form\Type\GenderType; use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface; use Chill\PersonBundle\Entity\Person; use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Pagination\PaginatorFactory; +use Symfony\Component\Form\Extension\Core\Type\TelType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Chill\MainBundle\Form\Type\ChillDateType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -19,23 +23,29 @@ use Symfony\Component\Templating\EngineInterface; class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface { - protected EngineInterface $templating; - protected PaginatorFactory $paginatorFactory; - protected PersonACLAwareRepositoryInterface $personACLAwareRepository; + private EngineInterface $templating; + private PaginatorFactory $paginatorFactory; + private PersonACLAwareRepositoryInterface $personACLAwareRepository; + private ExtractDateFromPattern $extractDateFromPattern; + private ExtractPhonenumberFromPattern $extractPhonenumberFromPattern; public const NAME = "person_regular"; private const POSSIBLE_KEYS = [ '_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before', - 'birthdate-after', 'gender', 'nationality' + 'birthdate-after', 'gender', 'nationality', 'phonenumber', 'city' ]; public function __construct( EngineInterface $templating, + ExtractDateFromPattern $extractDateFromPattern, + ExtractPhonenumberFromPattern $extractPhonenumberFromPattern, PaginatorFactory $paginatorFactory, PersonACLAwareRepositoryInterface $personACLAwareRepository ) { $this->templating = $templating; + $this->extractDateFromPattern = $extractDateFromPattern; + $this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern; $this->paginatorFactory = $paginatorFactory; $this->personACLAwareRepository = $personACLAwareRepository; } @@ -69,6 +79,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf */ public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html') { + $terms = $this->findAdditionnalInDefault($terms); $total = $this->count($terms); $paginator = $this->paginatorFactory->create($total); @@ -99,6 +110,26 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf } } + private function findAdditionnalInDefault(array $terms): array + { + // chaining some extractor + $datesResults = $this->extractDateFromPattern->extractDates($terms['_default']); + $phoneResults = $this->extractPhonenumberFromPattern->extractPhonenumber($datesResults->getFilteredSubject()); + $terms['_default'] = $phoneResults->getFilteredSubject(); + + if ($datesResults->hasResult() && (!\array_key_exists('birthdate', $terms) + || NULL !== $terms['birthdate'])) { + $terms['birthdate'] = $datesResults->getFound()[0]->format('Y-m-d'); + } + + if ($phoneResults->hasResult() && (!\array_key_exists('phonenumber', $terms) + || NULL !== $terms['phonenumber'])) { + $terms['phonenumber'] = $phoneResults->getFound()[0]; + } + + return $terms; + } + /** * @return Person[] */ @@ -113,6 +144,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf 'birthdate-after' => $birthdateAfter, 'gender' => $gender, 'nationality' => $countryCode, + 'phonenumber' => $phonenumber, + 'city' => $city, ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null); foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) { @@ -139,6 +172,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf $birthdateAfter, $gender, $countryCode, + $phonenumber, + $city ); } @@ -153,7 +188,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf 'birthdate-after' => $birthdateAfter, 'gender' => $gender, 'nationality' => $countryCode, - + 'phonenumber' => $phonenumber, + 'city' => $city, ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null); foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) { @@ -177,6 +213,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf $birthdateAfter, $gender, $countryCode, + $phonenumber, + $city ); } @@ -207,13 +245,19 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf 'label' => 'Birthdate before', 'required' => false ]) - ->add('gender', ChoiceType::class, [ - 'choices' => [ - 'Man' => Person::MALE_GENDER, - 'Woman' => Person::FEMALE_GENDER - ], + ->add('phonenumber', TelType::class, [ + 'required' => false, + 'label' => 'Part of the phonenumber' + ]) + ->add('gender', GenderType::class, [ 'label' => 'Gender', - 'required' => false + 'required' => false, + 'expanded' => false, + 'placeholder' => 'All genders' + ]) + ->add('city', TextType::class, [ + 'required' => false, + 'label' => 'City or postal code' ]) ; } @@ -224,7 +268,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf $string .= empty($data['_default']) ? '' : $data['_default'].' '; - foreach(['firstname', 'lastname', 'gender'] as $key) { + foreach(['firstname', 'lastname', 'gender', 'phonenumber', 'city'] as $key) { $string .= empty($data[$key]) ? '' : $key.':'. // add quote if contains spaces (strpos($data[$key], ' ') !== false ? '"'.$data[$key].'"': $data[$key]) @@ -246,7 +290,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf { $data = []; - foreach(['firstname', 'lastname', 'gender', '_default'] as $key) { + foreach(['firstname', 'lastname', 'gender', '_default', 'phonenumber', 'city'] as $key) { $data[$key] = $terms[$key] ?? null; } @@ -275,6 +319,4 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf { return self::NAME; } - - } diff --git a/src/Bundle/ChillPersonBundle/Search/PersonSearchByPhone.php b/src/Bundle/ChillPersonBundle/Search/PersonSearchByPhone.php deleted file mode 100644 index 08152144b..000000000 --- a/src/Bundle/ChillPersonBundle/Search/PersonSearchByPhone.php +++ /dev/null @@ -1,133 +0,0 @@ - - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -namespace Chill\PersonBundle\Search; - -use Chill\MainBundle\Search\AbstractSearch; -use Chill\PersonBundle\Repository\PersonRepository; -use Chill\PersonBundle\Entity\Person; -use Chill\MainBundle\Search\SearchInterface; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Chill\PersonBundle\Security\Authorization\PersonVoter; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; -use Chill\MainBundle\Pagination\PaginatorFactory; -use Symfony\Component\Security\Core\Role\Role; -use Symfony\Component\Templating\EngineInterface; - -/** - * - * - */ -class PersonSearchByPhone extends AbstractSearch -{ - - /** - * - * @var PersonRepository - */ - private $personRepository; - - /** - * - * @var TokenStorageInterface - */ - private $tokenStorage; - - /** - * - * @var AuthorizationHelper - */ - private $helper; - - /** - * - * @var PaginatorFactory - */ - protected $paginatorFactory; - - /** - * - * @var bool - */ - protected $activeByDefault; - - /** - * - * @var Templating - */ - protected $engine; - - const NAME = 'phone'; - - public function __construct( - PersonRepository $personRepository, - TokenStorageInterface $tokenStorage, - AuthorizationHelper $helper, - PaginatorFactory $paginatorFactory, - EngineInterface $engine, - $activeByDefault) - { - $this->personRepository = $personRepository; - $this->tokenStorage = $tokenStorage; - $this->helper = $helper; - $this->paginatorFactory = $paginatorFactory; - $this->engine = $engine; - $this->activeByDefault = $activeByDefault === 'always'; - } - - public function getOrder(): int - { - return 110; - } - - public function isActiveByDefault(): bool - { - return $this->activeByDefault; - } - - public function renderResult(array $terms, $start = 0, $limit = 50, $options = array(), $format = 'html') - { - $phonenumber = $terms['_default']; - $centers = $this->helper->getReachableCenters($this->tokenStorage - ->getToken()->getUser(), new Role(PersonVoter::SEE)); - $total = $this->personRepository - ->countByPhone($phonenumber, $centers); - $persons = $this->personRepository - ->findByPhone($phonenumber, $centers, $start, $limit) - ; - $paginator = $this->paginatorFactory - ->create($total); - - return $this->engine->render('ChillPersonBundle:Person:list_by_phonenumber.html.twig', - array( - 'persons' => $persons, - 'pattern' => $this->recomposePattern($terms, array(), $terms['_domain'] ?? self::NAME), - 'phonenumber' => $phonenumber, - 'total' => $total, - 'start' => $start, - 'search_name' => self::NAME, - 'preview' => $options[SearchInterface::SEARCH_PREVIEW_OPTION], - 'paginator' => $paginator - )); - } - - public function supports($domain, $format): bool - { - return $domain === 'phone' && $format === 'html'; - } -} diff --git a/src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php b/src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php index 60122dbc2..1677072a4 100644 --- a/src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php +++ b/src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php @@ -2,12 +2,13 @@ namespace Chill\PersonBundle\Search; -use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Search\Utils\ExtractDateFromPattern; +use Chill\MainBundle\Search\Utils\ExtractPhonenumberFromPattern; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; +use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface; use Chill\PersonBundle\Repository\PersonRepository; use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Search\SearchApiInterface; -use Chill\PersonBundle\Security\Authorization\PersonVoter; use Symfony\Component\Security\Core\Security; class SearchPersonApiProvider implements SearchApiInterface @@ -15,59 +16,47 @@ class SearchPersonApiProvider implements SearchApiInterface private PersonRepository $personRepository; private Security $security; private AuthorizationHelperInterface $authorizationHelper; + private ExtractDateFromPattern $extractDateFromPattern; + private PersonACLAwareRepositoryInterface $personACLAwareRepository; + private ExtractPhonenumberFromPattern $extractPhonenumberFromPattern; - public function __construct(PersonRepository $personRepository, Security $security, AuthorizationHelperInterface $authorizationHelper) - { + public function __construct( + PersonRepository $personRepository, + PersonACLAwareRepositoryInterface $personACLAwareRepository, + Security $security, + AuthorizationHelperInterface $authorizationHelper, + ExtractDateFromPattern $extractDateFromPattern, + ExtractPhonenumberFromPattern $extractPhonenumberFromPattern + ) { $this->personRepository = $personRepository; + $this->personACLAwareRepository = $personACLAwareRepository; $this->security = $security; $this->authorizationHelper = $authorizationHelper; + $this->extractDateFromPattern = $extractDateFromPattern; + $this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern; } public function provideQuery(string $pattern, array $parameters): SearchApiQuery { - return $this->addAuthorizations($this->buildBaseQuery($pattern, $parameters)); - } + $datesResult = $this->extractDateFromPattern->extractDates($pattern); + $phoneResult = $this->extractPhonenumberFromPattern->extractPhonenumber($datesResult->getFilteredSubject()); + $filtered = $phoneResult->getFilteredSubject(); - public function buildBaseQuery(string $pattern, array $parameters): SearchApiQuery - { - $query = new SearchApiQuery(); - $query + return $this->personACLAwareRepository->buildAuthorizedQuery( + $filtered, + null, + null, + count($datesResult->getFound()) > 0 ? $datesResult->getFound()[0] : null, + null, + null, + null, + null, + count($phoneResult->getFound()) > 0 ? $phoneResult->getFound()[0] : null + ) ->setSelectKey("person") - ->setSelectJsonbMetadata("jsonb_build_object('id', person.id)") - ->setSelectPertinence("". - "STRICT_WORD_SIMILARITY(LOWER(UNACCENT(?)), person.fullnamecanonical) + ". - "(person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%')::int + ". - "(EXISTS (SELECT 1 FROM unnest(string_to_array(fullnamecanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int" - , [ $pattern, $pattern, $pattern ]) - ->setFromClause("chill_person_person AS person") - ->setWhereClauses("LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ". - "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ", [ $pattern, $pattern ]) - ; - - return $query; + ->setSelectJsonbMetadata("jsonb_build_object('id', person.id)"); } - private function addAuthorizations(SearchApiQuery $query): SearchApiQuery - { - $authorizedCenters = $this->authorizationHelper - ->getReachableCenters($this->security->getUser(), PersonVoter::SEE); - - if ([] === $authorizedCenters) { - return $query->andWhereClause("FALSE = TRUE", []); - } - - return $query - ->andWhereClause( - strtr( - "person.center_id IN ({{ center_ids }})", - [ - '{{ center_ids }}' => \implode(', ', - \array_fill(0, count($authorizedCenters), '?')), - ] - ), - \array_map(function(Center $c) {return $c->getId();}, $authorizedCenters) - ); - } public function supportsTypes(string $pattern, array $types, array $parameters): bool { diff --git a/src/Bundle/ChillPersonBundle/Search/SimilarityPersonSearch.php b/src/Bundle/ChillPersonBundle/Search/SimilarityPersonSearch.php deleted file mode 100644 index e62c2ee55..000000000 --- a/src/Bundle/ChillPersonBundle/Search/SimilarityPersonSearch.php +++ /dev/null @@ -1,114 +0,0 @@ -paginatorFactory = $paginatorFactory; - $this->personACLAwareRepository = $personACLAwareRepository; - $this->templating = $templating; - } - - /* - * (non-PHPdoc) - * @see \Chill\MainBundle\Search\SearchInterface::getOrder() - */ - public function getOrder() - { - return 200; - } - - /* - * (non-PHPdoc) - * @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault() - */ - public function isActiveByDefault() - { - return true; - } - - public function supports($domain, $format) - { - return 'person' === $domain; - } - - /** - * @param array $terms - * @param int $start - * @param int $limit - * @param array $options - * @param string $format - */ - public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html') - { - $total = $this->count($terms); - $paginator = $this->paginatorFactory->create($total); - - if ($format === 'html') { - if ($total !== 0) { - return $this->templating->render('ChillPersonBundle:Person:list.html.twig', - array( - 'persons' => $this->search($terms, $start, $limit, $options), - 'pattern' => $this->recomposePattern($terms, array('nationality', - 'firstname', 'lastname', 'birthdate', 'gender', - 'birthdate-before','birthdate-after'), $terms['_domain']), - 'total' => $total, - 'start' => $start, - 'search_name' => self::NAME, - 'preview' => $options[SearchInterface::SEARCH_PREVIEW_OPTION], - 'paginator' => $paginator, - 'title' => "Similar persons" - )); - } - else { - return null; - } - - } elseif ($format === 'json') { - return [ - 'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])), - 'pagination' => [ - 'more' => $paginator->hasNextPage() - ] - ]; - } - } - - /** - * - * @param string $pattern - * @param int $start - * @param int $limit - * @param array $options - * @return Person[] - */ - protected function search(array $terms, $start, $limit, array $options = array()) - { - return $this->personACLAwareRepository - ->findBySimilaritySearch($terms['_default'], $start, $limit, $options['simplify'] ?? false); - } - - protected function count(array $terms) - { - return $this->personACLAwareRepository->countBySimilaritySearch($terms['_default']); - } -} diff --git a/src/Bundle/ChillPersonBundle/config/services/search.yaml b/src/Bundle/ChillPersonBundle/config/services/search.yaml index 6a7cd4496..5428c8105 100644 --- a/src/Bundle/ChillPersonBundle/config/services/search.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/search.yaml @@ -4,11 +4,6 @@ services: tags: - { name: chill.search, alias: 'person_regular' } - Chill\PersonBundle\Search\SimilarityPersonSearch: - autowire: true - tags: - - { name: chill.search, alias: 'person_similarity' } - Chill\PersonBundle\Search\SimilarPersonMatcher: autowire: true autoconfigure: true diff --git a/src/Bundle/ChillPersonBundle/config/services/search_by_phone.yaml b/src/Bundle/ChillPersonBundle/config/services/search_by_phone.yaml deleted file mode 100644 index 4a20e898a..000000000 --- a/src/Bundle/ChillPersonBundle/config/services/search_by_phone.yaml +++ /dev/null @@ -1,11 +0,0 @@ -services: - Chill\PersonBundle\Search\PersonSearchByPhone: - arguments: - - '@Chill\PersonBundle\Repository\PersonRepository' - - '@security.token_storage' - - '@chill.main.security.authorization.helper' - - '@chill_main.paginator_factory' - - '@Symfony\Component\Templating\EngineInterface' - - '%chill_person.search.search_by_phone%' - tags: - - { name: chill.search, alias: 'person_by_phone' } \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20211119211101.php b/src/Bundle/ChillPersonBundle/migrations/Version20211119211101.php new file mode 100644 index 000000000..3f4a141d6 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20211119211101.php @@ -0,0 +1,28 @@ +addSql('CREATE INDEX person_birthdate ON chill_person_person (birthdate)'); + $this->addSql('CREATE INDEX phonenumber ON chill_person_phone USING GIST (phonenumber gist_trgm_ops)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX person_birthdate'); + $this->addSql('DROP INDEX phonenumber'); + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index 43f83059b..39b646c1d 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -12,6 +12,14 @@ Requestor: >- other {Demandeur·euse} } +person: + and_himself: >- + {gender, select, + man {et lui-même} + woman {et elle-même} + other {et lui·elle-même} + } + household: Household: Ménage Household number: Ménage {household_num} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 5054a3922..6dc4aebed 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -84,6 +84,7 @@ Married: Marié(e) File number: Dossier n° Civility: Civilité choose civility: -- +All genders: tous les genres # dédoublonnage Old person: Doublon @@ -207,6 +208,7 @@ Referrer: Référent Some peoples does not belong to any household currently. Add them to an household soon: Certaines personnes n'appartiennent à aucun ménage actuellement. Renseignez leur appartenance à un ménage dès que possible. Add to household now: Ajouter à un ménage Any resource for this accompanying course: Aucun interlocuteur privilégié pour ce parcours +course.draft: Brouillon # pickAPersonType Pick a person: Choisir une personne