Merge branch 'features/person-search-improve' into 'master'

refactor search for using search by pertinence

See merge request Chill-Projet/chill-bundles!224

# Description of changes

* 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
* inclure les droits de vendée dans la recherche par l'api

# Issues related

* https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/300
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/182
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/193
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/212
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/169
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/149
* https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/156

# Tests

* add tests for extracting phonenumber
* add tests for extracting dates
* test for search still exists
This commit is contained in:
Julien Fastré 2021-11-22 08:28:22 +00:00
commit 46a4afe1b3
41 changed files with 1071 additions and 727 deletions

View File

@ -11,8 +11,28 @@ and this project adheres to
## Unreleased ## Unreleased
<!-- write down unreleased development here --> <!-- write down unreleased development here -->
* [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] 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 * [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) * [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]: 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) * [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 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 * [person] do not ask for center any more on person creation
* [3party] do not ask for center any more on 3party 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 ### Test release 2021-11-08

View File

@ -705,11 +705,6 @@ parameters:
count: 1 count: 1
path: src/Bundle/ChillPersonBundle/Search/SearchPersonApiProvider.php 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\\.$#" message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#"
count: 1 count: 1

View File

@ -1,4 +1,2 @@
{{ dump(notification) }}
<a href="{{ path('chill_activity_activity_show', {'id': notification.relatedEntityId }) }}">Go to Activity</a> <a href="{{ path('chill_activity_activity_show', {'id': notification.relatedEntityId }) }}">Go to Activity</a>

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Activity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211119173555 extends AbstractMigration
{
public function getDescription(): string
{
return 'remove comment on deprecated json_array type';
}
public function up(Schema $schema): void
{
$columns = [
'activitytype.name',
'activitytypecategory.name'
];
foreach ($columns as $col) {
$this->addSql("COMMENT ON COLUMN $col IS NULL");
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211119173557 extends AbstractMigration
{
public function getDescription(): string
{
return 'remove comment on deprecated json_array type';
}
public function up(Schema $schema): void
{
$columns = [
'chill_calendar.cancel_reason.name',
'chill_calendar.invite.status',
];
foreach ($columns as $col) {
$this->addSql("COMMENT ON COLUMN $col IS NULL");
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\DocGenerator;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211119173556 extends AbstractMigration
{
public function getDescription(): string
{
return 'remove comment on deprecated json_array type';
}
public function up(Schema $schema): void
{
$columns = [
'chill_docgen_template.name'
];
foreach ($columns as $col) {
$this->addSql("COMMENT ON COLUMN $col IS NULL");
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211119173558 extends AbstractMigration
{
public function getDescription(): string
{
return 'remove comment on deprecated json_array type';
}
public function up(Schema $schema): void
{
$columns = [
'chill_doc.document_category.name',
'chill_doc.stored_object.key',
'chill_doc.stored_object.iv',
'chill_doc.stored_object.datas',
];
foreach ($columns as $col) {
$this->addSql("COMMENT ON COLUMN $col IS NULL");
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -3,6 +3,7 @@
namespace Chill\MainBundle; namespace Chill\MainBundle;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchApiInterface;
use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Security\Authorization\ChillVoterInterface; use Chill\MainBundle\Security\Authorization\ChillVoterInterface;
use Chill\MainBundle\Security\ProvideRoleInterface; use Chill\MainBundle\Security\ProvideRoleInterface;
@ -41,6 +42,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.scope_resolver'); ->addTag('chill_main.scope_resolver');
$container->registerForAutoconfiguration(ChillEntityRenderInterface::class) $container->registerForAutoconfiguration(ChillEntityRenderInterface::class)
->addTag('chill.render_entity'); ->addTag('chill.render_entity');
$container->registerForAutoconfiguration(SearchApiInterface::class)
->addTag('chill.search_api_provider');
$container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new SearchableServicesCompilerPass());
$container->addCompilerPass(new ConfigConsistencyCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass());

View File

@ -5,7 +5,7 @@
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ installation.name }} - {% block title %}{% endblock %}</title> <title>{{ installation.name }} - {% block title %}{{ 'Homepage'|trans }}{% endblock %}</title>
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon"> <link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
{{ encore_entry_link_tags('mod_bootstrap') }} {{ encore_entry_link_tags('mod_bootstrap') }}
@ -68,6 +68,9 @@
<button type="submit" class="btn btn-lg btn-warning mt-3"> <button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }} <i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button> </button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</center> </center>
</form> </form>
</div> </div>

View File

@ -78,11 +78,11 @@ abstract class AbstractSearch implements SearchInterface
$recomposed .= ' '.$term.':'; $recomposed .= ' '.$term.':';
$containsSpace = \strpos($terms[$term], " ") !== false; $containsSpace = \strpos($terms[$term], " ") !== false;
if ($containsSpace) { if ($containsSpace) {
$recomposed .= "("; $recomposed .= '"';
} }
$recomposed .= (mb_stristr(' ', $terms[$term]) === FALSE) ? $terms[$term] : '('.$terms[$term].')'; $recomposed .= (mb_stristr(' ', $terms[$term]) === FALSE) ? $terms[$term] : '('.$terms[$term].')';
if ($containsSpace) { if ($containsSpace) {
$recomposed .= ")"; $recomposed .= '"';
} }
} }
} }

View File

@ -20,19 +20,15 @@ class SearchApi
private EntityManagerInterface $em; private EntityManagerInterface $em;
private PaginatorFactory $paginator; private PaginatorFactory $paginator;
private array $providers = []; private iterable $providers = [];
public function __construct( public function __construct(
EntityManagerInterface $em, EntityManagerInterface $em,
SearchPersonApiProvider $searchPerson, iterable $providers,
ThirdPartyApiSearch $thirdPartyApiSearch,
SearchUserApiProvider $searchUser,
PaginatorFactory $paginator PaginatorFactory $paginator
) { ) {
$this->em = $em; $this->em = $em;
$this->providers[] = $searchPerson; $this->providers = $providers;
$this->providers[] = $thirdPartyApiSearch;
$this->providers[] = $searchUser;
$this->paginator = $paginator; $this->paginator = $paginator;
} }
@ -68,10 +64,15 @@ class SearchApi
private function findProviders(string $pattern, array $types, array $parameters): array private function findProviders(string $pattern, array $types, array $parameters): array
{ {
return \array_filter( $providers = [];
$this->providers,
fn($p) => $p->supportsTypes($pattern, $types, $parameters) foreach ($this->providers as $provider) {
); if ($provider->supportsTypes($pattern, $types, $parameters)) {
$providers[] = $provider;
}
}
return $providers;
} }
private function countItems($providers, $types, $parameters): int private function countItems($providers, $types, $parameters): int
@ -82,12 +83,12 @@ class SearchApi
$countNq = $this->em->createNativeQuery($countQuery, $rsmCount); $countNq = $this->em->createNativeQuery($countQuery, $rsmCount);
$countNq->setParameters($parameters); $countNq->setParameters($parameters);
return $countNq->getSingleScalarResult(); return (int) $countNq->getSingleScalarResult();
} }
private function buildCountQuery(array $queries, $types, $parameters) 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 = []; $unions = [];
$parameters = []; $parameters = [];
@ -141,17 +142,20 @@ class SearchApi
private function prepareProviders(array $rawResults) private function prepareProviders(array $rawResults)
{ {
$metadatas = []; $metadatas = [];
$providers = [];
foreach ($rawResults as $r) { foreach ($rawResults as $r) {
foreach ($this->providers as $k => $p) { foreach ($this->providers as $k => $p) {
if ($p->supportsResult($r['key'], $r['metadata'])) { if ($p->supportsResult($r['key'], $r['metadata'])) {
$metadatas[$k][] = $r['metadata']; $metadatas[$k][] = $r['metadata'];
$providers[$k] = $p;
break; break;
} }
} }
} }
foreach ($metadatas as $k => $m) { foreach ($metadatas as $k => $m) {
$this->providers[$k]->prepare($m); $providers[$k]->prepare($m);
} }
} }

View File

@ -4,6 +4,8 @@ namespace Chill\MainBundle\Search;
class SearchApiQuery class SearchApiQuery
{ {
private array $select = [];
private array $selectParams = [];
private ?string $selectKey = null; private ?string $selectKey = null;
private array $selectKeyParams = []; private array $selectKeyParams = [];
private ?string $jsonbMetadata = null; private ?string $jsonbMetadata = null;
@ -15,6 +17,38 @@ class SearchApiQuery
private array $whereClauses = []; private array $whereClauses = [];
private array $whereClausesParams = []; 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 public function setSelectKey(string $selectKey, array $params = []): self
{ {
$this->selectKey = $selectKey; $this->selectKey = $selectKey;
@ -47,6 +81,16 @@ class SearchApiQuery
return $this; 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. * Set the where clause and replace all existing ones.
* *
@ -54,7 +98,7 @@ class SearchApiQuery
public function setWhereClauses(string $whereClause, array $params = []): self public function setWhereClauses(string $whereClause, array $params = []): self
{ {
$this->whereClauses = [$whereClause]; $this->whereClauses = [$whereClause];
$this->whereClausesParams = [$params]; $this->whereClausesParams = $params;
return $this; return $this;
} }
@ -71,11 +115,53 @@ class SearchApiQuery
public function andWhereClause(string $whereClause, array $params = []): self public function andWhereClause(string $whereClause, array $params = []): self
{ {
$this->whereClauses[] = $whereClause; $this->whereClauses[] = $whereClause;
$this->whereClausesParams[] = $params; \array_push($this->whereClausesParams, ...$params);
return $this; 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 public function buildQuery(bool $countOnly = false): string
{ {
$isMultiple = count($this->whereClauses); $isMultiple = count($this->whereClauses);
@ -87,19 +173,8 @@ class SearchApiQuery
($isMultiple ? ')' : '') ($isMultiple ? ')' : '')
; ;
if (!$countOnly) { $select = $this->buildSelectClause($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";
}
return \strtr("SELECT return \strtr("SELECT
{select} {select}
@ -116,18 +191,16 @@ class SearchApiQuery
public function buildParameters(bool $countOnly = false): array public function buildParameters(bool $countOnly = false): array
{ {
if (!$countOnly) { if (!$countOnly) {
return \array_merge( return [
$this->selectKeyParams, ...$this->buildSelectParams($countOnly),
$this->jsonbMetadataParams, ...$this->fromClauseParams,
$this->pertinenceParams, ...$this->whereClausesParams,
$this->fromClauseParams, ];
\array_merge([], ...$this->whereClausesParams),
);
} else { } else {
return \array_merge( return [
$this->fromClauseParams, ...$this->fromClauseParams,
\array_merge([], ...$this->whereClausesParams), ...$this->whereClausesParams,
); ];
} }
} }
} }

View File

@ -121,14 +121,15 @@ class SearchProvider
private function extractTerms(&$subject) private function extractTerms(&$subject)
{ {
$terms = array(); $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) { foreach ($matches[2] as $key => $match) {
//remove from search pattern //remove from search pattern
$this->mustBeExtracted[] = $matches[0][$key]; $this->mustBeExtracted[] = $matches[0][$key];
//strip parenthesis //strip parenthesis
if (mb_substr($match, 0, 1) === '(' && if (mb_substr($match, 0, 1) === '"' &&
mb_substr($match, mb_strlen($match) - 1) === ')') { mb_substr($match, mb_strlen($match) - 1) === '"') {
$match = trim(mb_substr($match, 1, mb_strlen($match) - 2)); $match = trim(mb_substr($match, 1, mb_strlen($match) - 2));
} }
$terms[$matches[1][$key]] = $match; $terms[$matches[1][$key]] = $match;

View File

@ -0,0 +1,36 @@
<?php
namespace Chill\MainBundle\Search\Utils;
use \DateTimeImmutable;
class ExtractDateFromPattern
{
private const DATE_PATTERN = [
["([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))", 'Y-m-d'], // 1981-05-12
["((0[1-9]|[12]\d|3[01])\/(0[1-9]|1[0-2])\/([12]\d{3}))", 'd/m/Y'], // 15/12/1980
["((0[1-9]|[12]\d|3[01])-(0[1-9]|1[0-2])-([12]\d{3}))", 'd-m-Y'], // 15/12/1980
];
public function extractDates(string $subject): SearchExtractionResult
{
$dates = [];
$filteredSubject = $subject;
foreach (self::DATE_PATTERN as [$pattern, $format]) {
$matches = [];
\preg_match_all($pattern, $filteredSubject, $matches);
foreach ($matches[0] as $match) {
$date = DateTimeImmutable::createFromFormat($format, $match);
if (false !== $date) {
$dates[] = $date;
// filter string: remove what is found
$filteredSubject = \trim(\strtr($filteredSubject, [$match => ""]));
}
}
}
return new SearchExtractionResult($filteredSubject, $dates);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Chill\MainBundle\Search\Utils;
class ExtractPhonenumberFromPattern
{
private const PATTERN = "([\+]{0,1}[0-9\ ]{5,})";
public function extractPhonenumber(string $subject): SearchExtractionResult
{
$matches = [];
\preg_match(self::PATTERN, $subject,$matches);
if (0 < count($matches)) {
$phonenumber = [];
$length = 0;
foreach (\str_split(\trim($matches[0])) as $key => $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, []);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Chill\MainBundle\Search\Utils;
class SearchExtractionResult
{
private string $filteredSubject;
private array $found;
public function __construct(string $filteredSubject, array $found)
{
$this->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;
}
}

View File

@ -12,7 +12,7 @@ class SearchApiQueryTest extends TestCase
$q = new SearchApiQuery(); $q = new SearchApiQuery();
$q->setSelectJsonbMetadata('boum') $q->setSelectJsonbMetadata('boum')
->setSelectKey('bim') ->setSelectKey('bim')
->setSelectPertinence('1') ->setSelectPertinence('?', ['gamma'])
->setFromClause('badaboum') ->setFromClause('badaboum')
->andWhereClause('foo', [ 'alpha' ]) ->andWhereClause('foo', [ 'alpha' ])
->andWhereClause('bar', [ 'beta' ]) ->andWhereClause('bar', [ 'beta' ])
@ -21,12 +21,12 @@ class SearchApiQueryTest extends TestCase
$query = $q->buildQuery(); $query = $q->buildQuery();
$this->assertStringContainsString('(foo) AND (bar)', $query); $this->assertStringContainsString('(foo) AND (bar)', $query);
$this->assertEquals(['alpha', 'beta'], $q->buildParameters()); $this->assertEquals(['gamma', 'alpha', 'beta'], $q->buildParameters());
$query = $q->buildQuery(true); $query = $q->buildQuery(true);
$this->assertStringContainsString('(foo) AND (bar)', $query); $this->assertStringContainsString('(foo) AND (bar)', $query);
$this->assertEquals(['alpha', 'beta'], $q->buildParameters()); $this->assertEquals(['gamma', 'alpha', 'beta'], $q->buildParameters());
} }
public function testWithoutWhereClause() public function testWithoutWhereClause()
@ -42,4 +42,20 @@ class SearchApiQueryTest extends TestCase
$this->assertEquals([], $q->buildParameters()); $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());
}
} }

View File

@ -57,7 +57,7 @@ class SearchProviderTest extends TestCase
public function testSimplePattern() 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( $this->assertEquals(array(
'_domain' => 'person', '_domain' => 'person',
@ -110,8 +110,7 @@ class SearchProviderTest extends TestCase
public function testDoubleParenthesis() public function testDoubleParenthesis()
{ {
$terms = $this->p("@papamobile name:(my beautiful name) residual " $terms = $this->p('@papamobile name:"my beautiful name" residual surname:"i love techno"');
. "surname:(i love techno)");
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => 'papamobile', '_domain' => 'papamobile',
@ -147,7 +146,7 @@ class SearchProviderTest extends TestCase
public function testTrimInParenthesis() public function testTrimInParenthesis()
{ {
$terms = $this->p('foo:(bar )'); $terms = $this->p('foo:"bar "');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => null, '_domain' => null,

View File

@ -0,0 +1,45 @@
<?php
namespace Search\Utils;
use Chill\MainBundle\Search\Utils\ExtractDateFromPattern;
use PHPUnit\Framework\TestCase;
class ExtractDateFromPatternTest extends TestCase
{
/**
* @dataProvider provideSubjects
*/
public function testExtractDate(string $subject, string $filtered, int $count, ...$datesSearched)
{
$extractor = new ExtractDateFromPattern();
$result = $extractor->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];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Search\Utils;
use Chill\MainBundle\Search\Utils\ExtractPhonenumberFromPattern;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ExtractPhonenumberFromPatternTest extends KernelTestCase
{
/**
* @dataProvider provideData
*/
public function testExtract($subject, $expectedCount, $expected, $filteredSubject, $msg)
{
$extractor = new ExtractPhonenumberFromPattern();
$result = $extractor->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"];
}
}

View File

@ -8,7 +8,16 @@ services:
Chill\MainBundle\Search\SearchProvider: '@chill_main.search_provider' 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\: Chill\MainBundle\Search\Entity\:
resource: '../../Search/Entity' resource: '../../Search/Entity'
Chill\MainBundle\Search\Utils\:
autowire: true
autoconfigure: true
resource: './../Search/Utils/'

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20211119173554 extends AbstractMigration
{
public function getDescription(): string
{
return 'remove comment on deprecated json_array type';
}
public function up(Schema $schema): void
{
$columns = [
'users.attributes'
];
foreach ($columns as $col) {
$this->addSql("COMMENT ON COLUMN $col IS NULL");
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -86,6 +86,10 @@ address more:
Create a new address: Créer une nouvelle adresse Create a new address: Créer une nouvelle adresse
Create an address: Créer une adresse Create an address: Créer une adresse
Update address: Modifier l'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 #serach
Your search is empty. Please provide search terms.: La recherche est vide. Merci de fournir des termes de recherche. Your search is empty. Please provide search terms.: La recherche est vide. Merci de fournir des termes de recherche.

View File

@ -86,13 +86,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$loader->load('services/security.yaml'); $loader->load('services/security.yaml');
$loader->load('services/doctrineEventListener.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') { if ($container->getParameter('chill_person.accompanying_period') !== 'hidden') {
$loader->load('services/exports_accompanying_period.yaml'); $loader->load('services/exports_accompanying_period.yaml');
} }

View File

@ -27,19 +27,6 @@ class Configuration implements ConfigurationInterface
$rootNode $rootNode
->canBeDisabled() ->canBeDisabled()
->children() ->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') ->arrayNode('validation')
->canBeDisabled() ->canBeDisabled()
->children() ->children()

View File

@ -434,6 +434,5 @@ class Household
->addViolation(); ->addViolation();
} }
} }
dump($cond);
} }
} }

View File

@ -39,10 +39,16 @@ use DateTimeInterface;
* *
* @ORM\Entity * @ORM\Entity
* @ORM\Table(name="chill_person_person", * @ORM\Table(name="chill_person_person",
* indexes={@ORM\Index( * indexes={
* @ORM\Index(
* name="person_names", * name="person_names",
* columns={"firstName", "lastName"} * columns={"firstName", "lastName"}
* )}) * ),
* @ORM\Index(
* name="person_birthdate",
* columns={"birthdate"}
* )
* })
* @ORM\HasLifecycleCallbacks() * @ORM\HasLifecycleCallbacks()
* @DiscriminatorMap(typeProperty="type", mapping={ * @DiscriminatorMap(typeProperty="type", mapping={
* "person"=Person::class * "person"=Person::class

View File

@ -9,7 +9,10 @@ use Doctrine\ORM\Mapping as ORM;
* Person Phones * Person Phones
* *
* @ORM\Entity * @ORM\Entity
* @ORM\Table(name="chill_person_phone") * @ORM\Table(name="chill_person_phone",
* indexes={
* @ORM\Index(name="phonenumber", columns={"phonenumber"})
* })
*/ */
class PersonPhone class PersonPhone
{ {

View File

@ -31,7 +31,7 @@ class GenderType extends AbstractType {
'choices' => $a, 'choices' => $a,
'expanded' => true, 'expanded' => true,
'multiple' => false, 'multiple' => false,
'placeholder' => null 'placeholder' => null,
)); ));
} }

View File

@ -2,11 +2,15 @@
namespace Chill\PersonBundle\Repository; namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApi;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException; use Doctrine\ORM\NoResultException;
@ -49,125 +53,114 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac
string $default = null, string $default = null,
string $firstname = null, string $firstname = null,
string $lastname = null, string $lastname = null,
?\DateTime $birthdate = null, ?\DateTimeInterface $birthdate = null,
?\DateTime $birthdateBefore = null, ?\DateTimeInterface $birthdateBefore = null,
?\DateTime $birthdateAfter = null, ?\DateTimeInterface $birthdateAfter = null,
string $gender = null, string $gender = null,
string $countryCode = null string $countryCode = null,
string $phonenumber = null,
string $city = null
): array { ): array {
$qb = $this->createSearchQuery($default, $firstname, $lastname, $query = $this->buildAuthorizedQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender, $birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode); $countryCode, $phonenumber, $city);
$this->addACLClauses($qb, 'p');
return $this->getQueryResult($qb, 'p', $simplify, $limit, $start); return $this->fetchQueryPerson($query);
}
/**
* 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();
}
} }
public function countBySearchCriteria( public function countBySearchCriteria(
string $default = null, string $default = null,
string $firstname = null, string $firstname = null,
string $lastname = null, string $lastname = null,
?\DateTime $birthdate = null, ?\DateTimeInterface $birthdate = null,
?\DateTime $birthdateBefore = null, ?\DateTimeInterface $birthdateBefore = null,
?\DateTime $birthdateAfter = null, ?\DateTimeInterface $birthdateAfter = null,
string $gender = null, string $gender = null,
string $countryCode = null string $countryCode = null,
string $phonenumber = null,
string $city = null
): int { ): int {
$qb = $this->createSearchQuery($default, $firstname, $lastname, $query = $this->buildAuthorizedQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender, $birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode); $countryCode, $phonenumber, $city)
$this->addACLClauses($qb, 'p'); ;
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 * @return array|Person[]
*
* 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.
*/ */
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, public function buildAuthorizedQuery(
int $maxResult, bool $simplify = false) string $default = null,
{ string $firstname = null,
$qb = $this->createSimilarityQuery($pattern); string $lastname = null,
$this->addACLClauses($qb, 'sp'); ?\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); $authorizedCenters = $this->authorizationHelper
$this->addACLClauses($qb, 'sp'); ->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 * 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 NonUniqueResultException
* @throws ParsingException * @throws ParsingException
*/ */
@ -175,118 +168,107 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac
string $default = null, string $default = null,
string $firstname = null, string $firstname = null,
string $lastname = null, string $lastname = null,
?\DateTime $birthdate = null, ?\DateTimeInterface $birthdate = null,
?\DateTime $birthdateBefore = null, ?\DateTimeInterface $birthdateBefore = null,
?\DateTime $birthdateAfter = null, ?\DateTimeInterface $birthdateAfter = null,
string $gender = null, string $gender = null,
string $countryCode = null string $countryCode = null,
): QueryBuilder { string $phonenumber = null,
string $city = null
): SearchApiQuery {
$query = new SearchApiQuery();
$query
->setFromClause("chill_person_person AS person")
;
if (!$this->security->getUser() instanceof User) { $pertinence = [];
throw new \RuntimeException("Search must be performed by a valid user"); $pertinenceArgs = [];
} $orWhereSearchClause = [];
$qb = $this->em->createQueryBuilder(); $orWhereSearchClauseArgs = [];
$qb->from(Person::class, 'p');
if (NULL !== $firstname) { if ("" !== $default) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname')) foreach (\explode(" ", $default) as $str) {
->setParameter('firstname', '%'.$firstname.'%'); $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);
$orWhereSearchClause[] =
"(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ".
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
\array_push($orWhereSearchClauseArgs, $str, $str);
} }
if (NULL !== $lastname) { $query->andWhereClause(\implode(' OR ', $orWhereSearchClause),
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname')) $orWhereSearchClauseArgs);
->setParameter('lastname', '%'.$lastname.'%'); } else {
$pertinence = ["1"];
$pertinenceArgs = [];
} }
$query
->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs)
;
if (NULL !== $birthdate) { if (NULL !== $birthdate) {
$qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate')) $query->andWhereClause(
->setParameter('birthdate', $birthdate); "person.birthdate = ?::date",
} [$birthdate->format('Y-m-d')]
if (NULL !== $birthdateAfter) {
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
->setParameter('birthdateafter', $birthdateAfter);
}
if (NULL !== $birthdateBefore) {
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
->setParameter('birthdatebefore', $birthdateBefore);
}
if (NULL !== $gender) {
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
->setParameter('gender', $gender);
}
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 !== $default) {
$grams = explode(' ', $default);
foreach($grams as $key => $gram) {
$qb->andWhere($qb->expr()
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
->setParameter('default_'.$key, '%'.$gram.'%');
}
}
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);
} }
if (NULL !== $firstname) {
/** $query->andWhereClause(
* Create a query for searching by similarity. "UNACCENT(LOWER(person.firstname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
* [$firstname]
* 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 !== $lastname) {
$query->andWhereClause(
"UNACCENT(LOWER(person.lastname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
[$lastname]
);
}
if (NULL !== $birthdateBefore) {
$query->andWhereClause(
'p.birthdate < ?::date',
[$birthdateBefore->format('Y-m-d')]
);
}
if (NULL !== $birthdateAfter) {
$query->andWhereClause(
'p.birthdate > ?::date',
[$birthdateAfter->format('Y-m-d')]
);
}
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");
return $qb; foreach (\explode(" ", $city) as $cityStr) {
$query->andWhereClause(
"(UNACCENT(LOWER(cmpc.label)) LIKE '%' || UNACCENT(LOWER(?)) || '%' OR cmpc.code LIKE '%' || UNACCENT(LOWER(?)) || '%')",
[$cityStr, $city]
);
}
}
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 $query;
} }
} }

View File

@ -3,16 +3,14 @@
namespace Chill\PersonBundle\Repository; namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
interface PersonACLAwareRepositoryInterface interface PersonACLAwareRepositoryInterface
{ {
/** /**
* @return array|Person[] * @return array|Person[]
* @throws NonUniqueResultException
* @throws ParsingException
*/ */
public function findBySearchCriteria( public function findBySearchCriteria(
int $start, int $start,
@ -21,30 +19,38 @@ interface PersonACLAwareRepositoryInterface
string $default = null, string $default = null,
string $firstname = null, string $firstname = null,
string $lastname = null, string $lastname = null,
?\DateTime $birthdate = null, ?\DateTimeInterface $birthdate = null,
?\DateTime $birthdateBefore = null, ?\DateTimeInterface $birthdateBefore = null,
?\DateTime $birthdateAfter = null, ?\DateTimeInterface $birthdateAfter = null,
string $gender = null, string $gender = null,
string $countryCode = null string $countryCode = null,
string $phonenumber = null,
string $city = null
): array; ): array;
public function countBySearchCriteria( public function countBySearchCriteria(
string $default = null, string $default = null,
string $firstname = null, string $firstname = null,
string $lastname = null, string $lastname = null,
?\DateTime $birthdate = null, ?\DateTimeInterface $birthdate = null,
?\DateTime $birthdateBefore = null, ?\DateTimeInterface $birthdateBefore = null,
?\DateTime $birthdateAfter = null, ?\DateTimeInterface $birthdateAfter = null,
string $gender = null, string $gender = null,
string $countryCode = null string $countryCode = null,
string $phonenumber = null,
string $city = null
); );
public function findBySimilaritySearch( public function buildAuthorizedQuery(
string $pattern, string $default = null,
int $firstResult, string $firstname = null,
int $maxResult, string $lastname = null,
bool $simplify = false ?\DateTimeInterface $birthdate = null,
); ?\DateTimeInterface $birthdateBefore = null,
?\DateTimeInterface $birthdateAfter = null,
public function countBySimilaritySearch(string $pattern); string $gender = null,
string $countryCode = null,
string $phonenumber = null,
string $city = null
): SearchApiQuery;
} }

View File

@ -1,7 +1,12 @@
{% macro button_person(person) %} {% macro button_person_after(person) %}
{% set household = person.getCurrentHousehold %}
{% if household is not null %}
<li> <li>
<a href="{{ path('chill_person_accompanying_course_new', { 'person_id': [ person.id ]}) }}" <a href="{{ path('chill_person_household_summary', { 'household_id': household.id }) }}" class="btn btn-sm btn-chill-beige"><i class="fa fa-home"></i></a>
class="btn btn-sm btn-create change-icon" title="{{ 'Create an accompanying period'|trans }}"><i class="fa fa-random"></i></a> </li>
{% endif %}
<li>
<a href="{{ path('chill_person_accompanying_course_new', { 'person_id': [ person.id ]}) }}" class="btn btn-sm btn-create change-icon" title="{{ 'Create an accompanying period'|trans }}"><i class="fa fa-random"></i></a>
</li> </li>
{% endmacro %} {% endmacro %}
@ -56,7 +61,7 @@
'addAltNames': true, 'addAltNames': true,
'addCenter': true, 'addCenter': true,
'address_multiline': false, 'address_multiline': false,
'customButtons': { 'after': _self.button_person(person) } 'customButtons': { 'after': _self.button_person_after(person) }
}) }} }) }}
{#- 'acps' is for AcCompanyingPeriodS #} {#- 'acps' is for AcCompanyingPeriodS #}
@ -76,12 +81,20 @@
<div class="wl-row separator"> <div class="wl-row separator">
<div class="wl-col title"> <div class="wl-col title">
<div class="date"> {% if acp.step == 'DRAFT' %}
<div class="is-draft">
<span class="course-draft badge bg-secondary" title="{{ 'course.draft'|trans }}">{{ 'course.draft'|trans }}</span>
</div>
{% endif %}
{% if acp.requestorPerson == person %} {% if acp.requestorPerson == person %}
<div>
<span class="as-requestor badge bg-info" title="{{ 'Requestor'|trans|e('html_attr') }}"> <span class="as-requestor badge bg-info" title="{{ 'Requestor'|trans|e('html_attr') }}">
{{ 'Requestor'|trans({'gender': person.gender}) }} {{ 'Requestor'|trans({'gender': person.gender}) }}
</span> </span>
</div>
{% endif %} {% endif %}
<div class="date">
{% if app != null %} {% if app != null %}
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }} {{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
{% endif %} {% endif %}
@ -94,6 +107,11 @@
</div> </div>
{% endif %} {% endif %}
<div class="courseid">
{{ 'File number'|trans }} {{ acp.id }}
</div>
</div> </div>
<div class="wl-col list"> <div class="wl-col list">
@ -101,17 +119,76 @@
{{ issue|chill_entity_render_box }} {{ issue|chill_entity_render_box }}
{% endfor %} {% endfor %}
<ul class="record_actions"> <ul class="record_actions record_actions_column">
<li> <li>
<a href="{{ path('chill_person_accompanying_course_index', { 'accompanying_period_id': acp.id }) }}" <a href="{{ path('chill_person_accompanying_course_index', { 'accompanying_period_id': acp.id }) }}"
class="btn btn-sm btn-outline-primary" title="{{ 'See accompanying period'|trans }}"> class="btn btn-sm btn-outline-primary" title="{{ 'See accompanying period'|trans }}">
<i class="fa fa-random fa-fw"></i> <i class="fa fa-random fa-fw"></i>
</a> </a>
</li> </li>
<li>
</li>
</ul> </ul>
</div> </div>
</div> </div>
{% if acp.currentParticipations|length > 1 %}
<div class="wl-row">
<div class="wl-col title">
<div class="participants">
{{ 'Participants'|trans }}
</div>
</div>
<div class="wl-col list">
{% 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 %}
</div>
</div>
{% endif %}
{% if (acp.requestorPerson is not null and acp.requestorPerson.id != person.id) or acp.requestorThirdParty is not null %}
<div class="wl-row">
<div class="wl-col title">
<div>
{% if acp.requestorPerson is not null %}
{{ 'Requestor'|trans({'gender': acp.requestorPerson.gender}) }}
{% else %}
{{ 'Requestor'|trans({'gender': 'other'})}}
{% endif %}
</div>
</div>
<div class="wl-col list">
{% 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 %}
</div>
</div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -5,11 +5,15 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Search; namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\AbstractSearch; 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\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@ -19,23 +23,29 @@ use Symfony\Component\Templating\EngineInterface;
class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface
{ {
protected EngineInterface $templating; private EngineInterface $templating;
protected PaginatorFactory $paginatorFactory; private PaginatorFactory $paginatorFactory;
protected PersonACLAwareRepositoryInterface $personACLAwareRepository; private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private ExtractDateFromPattern $extractDateFromPattern;
private ExtractPhonenumberFromPattern $extractPhonenumberFromPattern;
public const NAME = "person_regular"; public const NAME = "person_regular";
private const POSSIBLE_KEYS = [ private const POSSIBLE_KEYS = [
'_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before', '_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before',
'birthdate-after', 'gender', 'nationality' 'birthdate-after', 'gender', 'nationality', 'phonenumber', 'city'
]; ];
public function __construct( public function __construct(
EngineInterface $templating, EngineInterface $templating,
ExtractDateFromPattern $extractDateFromPattern,
ExtractPhonenumberFromPattern $extractPhonenumberFromPattern,
PaginatorFactory $paginatorFactory, PaginatorFactory $paginatorFactory,
PersonACLAwareRepositoryInterface $personACLAwareRepository PersonACLAwareRepositoryInterface $personACLAwareRepository
) { ) {
$this->templating = $templating; $this->templating = $templating;
$this->extractDateFromPattern = $extractDateFromPattern;
$this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern;
$this->paginatorFactory = $paginatorFactory; $this->paginatorFactory = $paginatorFactory;
$this->personACLAwareRepository = $personACLAwareRepository; $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') public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
{ {
$terms = $this->findAdditionnalInDefault($terms);
$total = $this->count($terms); $total = $this->count($terms);
$paginator = $this->paginatorFactory->create($total); $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[] * @return Person[]
*/ */
@ -113,6 +144,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'birthdate-after' => $birthdateAfter, 'birthdate-after' => $birthdateAfter,
'gender' => $gender, 'gender' => $gender,
'nationality' => $countryCode, 'nationality' => $countryCode,
'phonenumber' => $phonenumber,
'city' => $city,
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null); ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) { foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
@ -139,6 +172,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$birthdateAfter, $birthdateAfter,
$gender, $gender,
$countryCode, $countryCode,
$phonenumber,
$city
); );
} }
@ -153,7 +188,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'birthdate-after' => $birthdateAfter, 'birthdate-after' => $birthdateAfter,
'gender' => $gender, 'gender' => $gender,
'nationality' => $countryCode, 'nationality' => $countryCode,
'phonenumber' => $phonenumber,
'city' => $city,
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null); ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) { foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
@ -177,6 +213,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$birthdateAfter, $birthdateAfter,
$gender, $gender,
$countryCode, $countryCode,
$phonenumber,
$city
); );
} }
@ -207,13 +245,19 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'label' => 'Birthdate before', 'label' => 'Birthdate before',
'required' => false 'required' => false
]) ])
->add('gender', ChoiceType::class, [ ->add('phonenumber', TelType::class, [
'choices' => [ 'required' => false,
'Man' => Person::MALE_GENDER, 'label' => 'Part of the phonenumber'
'Woman' => Person::FEMALE_GENDER ])
], ->add('gender', GenderType::class, [
'label' => 'Gender', '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'].' '; $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.':'. $string .= empty($data[$key]) ? '' : $key.':'.
// add quote if contains spaces // add quote if contains spaces
(strpos($data[$key], ' ') !== false ? '"'.$data[$key].'"': $data[$key]) (strpos($data[$key], ' ') !== false ? '"'.$data[$key].'"': $data[$key])
@ -246,7 +290,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
{ {
$data = []; $data = [];
foreach(['firstname', 'lastname', 'gender', '_default'] as $key) { foreach(['firstname', 'lastname', 'gender', '_default', 'phonenumber', 'city'] as $key) {
$data[$key] = $terms[$key] ?? null; $data[$key] = $terms[$key] ?? null;
} }
@ -275,6 +319,4 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
{ {
return self::NAME; return self::NAME;
} }
} }

View File

@ -1,133 +0,0 @@
<?php
/*
*
* Copyright (C) 2014-2019, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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';
}
}

View File

@ -2,12 +2,13 @@
namespace Chill\PersonBundle\Search; 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\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\PersonRepository;
use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Search\SearchApiInterface; use Chill\MainBundle\Search\SearchApiInterface;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
class SearchPersonApiProvider implements SearchApiInterface class SearchPersonApiProvider implements SearchApiInterface
@ -15,59 +16,47 @@ class SearchPersonApiProvider implements SearchApiInterface
private PersonRepository $personRepository; private PersonRepository $personRepository;
private Security $security; private Security $security;
private AuthorizationHelperInterface $authorizationHelper; 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->personRepository = $personRepository;
$this->personACLAwareRepository = $personACLAwareRepository;
$this->security = $security; $this->security = $security;
$this->authorizationHelper = $authorizationHelper; $this->authorizationHelper = $authorizationHelper;
$this->extractDateFromPattern = $extractDateFromPattern;
$this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern;
} }
public function provideQuery(string $pattern, array $parameters): SearchApiQuery 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 return $this->personACLAwareRepository->buildAuthorizedQuery(
{ $filtered,
$query = new SearchApiQuery(); null,
$query null,
count($datesResult->getFound()) > 0 ? $datesResult->getFound()[0] : null,
null,
null,
null,
null,
count($phoneResult->getFound()) > 0 ? $phoneResult->getFound()[0] : null
)
->setSelectKey("person") ->setSelectKey("person")
->setSelectJsonbMetadata("jsonb_build_object('id', person.id)") ->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;
} }
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 public function supportsTypes(string $pattern, array $types, array $parameters): bool
{ {

View File

@ -1,114 +0,0 @@
<?php
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\AbstractSearch;
use Chill\MainBundle\Search\SearchInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Symfony\Component\Templating\EngineInterface;
class SimilarityPersonSearch extends AbstractSearch
{
protected PaginatorFactory $paginatorFactory;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private EngineInterface $templating;
const NAME = "person_similarity";
public function __construct(
PaginatorFactory $paginatorFactory,
PersonACLAwareRepositoryInterface $personACLAwareRepository,
EngineInterface $templating
) {
$this->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']);
}
}

View File

@ -4,11 +4,6 @@ services:
tags: tags:
- { name: chill.search, alias: 'person_regular' } - { name: chill.search, alias: 'person_regular' }
Chill\PersonBundle\Search\SimilarityPersonSearch:
autowire: true
tags:
- { name: chill.search, alias: 'person_similarity' }
Chill\PersonBundle\Search\SimilarPersonMatcher: Chill\PersonBundle\Search\SimilarPersonMatcher:
autowire: true autowire: true
autoconfigure: true autoconfigure: true

View File

@ -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' }

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211119211101 extends AbstractMigration
{
public function getDescription(): string
{
return 'add indexes for searching by birthdate';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -12,6 +12,14 @@ Requestor: >-
other {Demandeur·euse} 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:
Household: Ménage Household: Ménage
Household number: Ménage {household_num} Household number: Ménage {household_num}

View File

@ -84,6 +84,7 @@ Married: Marié(e)
File number: Dossier n° File number: Dossier n°
Civility: Civilité Civility: Civilité
choose civility: -- choose civility: --
All genders: tous les genres
# dédoublonnage # dédoublonnage
Old person: Doublon 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. 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 Add to household now: Ajouter à un ménage
Any resource for this accompanying course: Aucun interlocuteur privilégié pour ce parcours Any resource for this accompanying course: Aucun interlocuteur privilégié pour ce parcours
course.draft: Brouillon
# pickAPersonType # pickAPersonType
Pick a person: Choisir une personne Pick a person: Choisir une personne