add search by phone + format phonenumber

This commit is contained in:
Julien Fastré 2019-10-30 22:54:24 +01:00
parent 122aa3a993
commit f1eefa7ed7
19 changed files with 469 additions and 26 deletions

View File

@ -48,3 +48,23 @@ Version 1.5.7
- add a link between accompanying person and user
- add an icon when the file is opened / closed in result list, and in person rendering macro
- improve command to move person and all data: allow to delete some entities during move and add events
Master branch
=============
- add search by phonenumber, with a custom SearchInterface
This can be activated or desactivated by config:
```
chill_person:
enabled: true
search:
enabled: true
# 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
search_by_phone: on-domain # One of "always"; "on-domain"; "never"
```
- format phonenumber using twilio (if available) ;
- add `record_actions` in person search result list: users can click on a little eye to open person page ;

View File

@ -44,10 +44,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
// set configuration for double metaphone
$container->setParameter('cl_chill_person.search.use_double_metaphone',
$config['search']['use_double_metaphone']);
// set configuration for validation
$container->setParameter('chill_person.validation.birtdate_not_before',
$config['validation']['birthdate_not_after']);
@ -67,6 +63,14 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$loader->load('services/command.yml');
$loader->load('services/actions.yml');
$loader->load('services/form.yml');
$loader->load('services/repository.yml');
// load service advanced search only if configure
if ($config['search']['search_by_phone'] != 'never') {
$loader->load('services/search_by_phone.yml');
$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.yml');

View File

@ -30,12 +30,14 @@ class Configuration implements ConfigurationInterface
->arrayNode('search')
->canBeDisabled()
->children()
->booleanNode('use_double_metaphone')
->defaultFalse()
->end() // use_double_metaphone, parent = children for 'search'
->booleanNode('use_trigrams')
->defaultFalse()
->end() // use_trigrams, parent = children of 'search'
->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')

View File

@ -1,15 +1,30 @@
<?php
/*
* Copyright (C) 2014-2019 Julien Fastré <julien.fastre@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\Entity;
use Doctrine\ORM\EntityRepository;
@trigger_error(__CLASS__." is deprecated since 2019-10-30. Use "
.\Chill\PersonBundle\Repository\PersonRepository::class.' instead.',
E_USER_DEPRECATED);
/**
* PersonRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*
* @deprecated since 2019-10-30. Use \Chill\PersonBundle\Repository\PersonRepository instead.
*/
class PersonRepository extends EntityRepository
class PersonRepository extends \Chill\PersonBundle\Repository\PersonRepository
{
}

View File

@ -0,0 +1,100 @@
<?php
/*
* Copyright (C) 2019 Julien Fastré <julien.fastre@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\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* PersonRepository
*
*/
class PersonRepository extends EntityRepository
{
public function findByPhone(
string $phonenumber,
$centers,
$firstResult,
$maxResults,
array $only = ['mobile', 'phone']
) {
$qb = $this->createQueryBuilder('p');
$qb->select('p');
$this->addByCenters($qb, $centers);
$this->addPhoneNumber($qb, $phonenumber, $only);
$qb->setFirstResult($firstResult)
->setMaxResults($maxResults)
;
return $qb->getQuery()->getResult();
}
public function countByPhone(
string $phonenumber,
$centers,
array $only = ['mobile', 'phone']
): int
{
$qb = $this->createQueryBuilder('p');
$qb->select('COUNT(p)');
$this->addByCenters($qb, $centers);
$this->addPhoneNumber($qb, $phonenumber, $only);
return $qb->getQuery()->getSingleScalarResult();
}
protected function addPhoneNumber(QueryBuilder $qb, string $phonenumber, array $only)
{
if (count($only) === 0) {
throw new \Exception("No array field to search");
}
$phonenumber = $this->parsePhoneNumber($phonenumber);
$orX = $qb->expr()->orX();
if (\in_array('mobile', $only)) {
$orX->add($qb->expr()->like("REPLACE(p.mobilenumber, ' ', '')", ':phonenumber'));
}
if (\in_array('phone', $only)) {
$orX->add($qb->expr()->like("REPLACE(p.phonenumber, ' ', '')", ':phonenumber'));
}
$qb->andWhere($orX);
$qb->setParameter('phonenumber', '%'.$phonenumber.'%');
}
protected function parsePhoneNumber($phonenumber): string
{
return \str_replace(' ', '', $phonenumber);
}
protected function addByCenters(QueryBuilder $qb, array $centers)
{
if (count($centers) > 0) {
$qb->andWhere($qb->expr()->in('p.center', ':centers'));
$qb->setParameter('centers', $centers);
}
}
}

View File

@ -4,7 +4,7 @@ Chill\PersonBundle\Entity\Person:
indexes:
person_names:
columns: [firstName, lastName]
repositoryClass: Chill\PersonBundle\Entity\PersonRepository
repositoryClass: Chill\PersonBundle\Repository\PersonRepository
fields:
id:
type: integer

View File

@ -47,9 +47,3 @@ services:
tags:
- { name: form.type, alias: chill_personbundle_person_creation }
chill.person.repository.person:
class: Chill\PersonBundle\Entity\PersonRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments:
- 'Chill\PersonBundle\Entity\Person'

View File

@ -0,0 +1,13 @@
services:
chill.person.repository.person:
class: Chill\PersonBundle\Person\PersonRepository
deprecated: the service '%service_id%' is deprecated since 2019-10-30 and will be removed soon. Use 'Chill\PersonBundle\Repository\PersonRepository' instead
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments:
- 'Chill\PersonBundle\Entity\Person'
Chill\PersonBundle\Repository\PersonRepository:
class: Chill\PersonBundle\Person\PersonRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments:
- 'Chill\PersonBundle\Entity\Person'

View File

@ -0,0 +1,11 @@
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,5 @@
require('./phone-alt-solid.svg');
require('./mobile-alt-solid.svg');
require('./person_by_phonenumber.scss');

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="mobile-alt" class="svg-inline--fa fa-mobile-alt fa-w-10" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M272 0H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h224c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zM160 480c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm112-108c0 6.6-5.4 12-12 12H60c-6.6 0-12-5.4-12-12V60c0-6.6 5.4-12 12-12h200c6.6 0 12 5.4 12 12v312z"></path></svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1,25 @@
.person-list__--by-phonenumber {
.person-list__--by-phonenumber__phones {
ul {
list-style: none inside;
padding: 0;
margin: 0;
li {
margin: 0.80rem;
img {
vertical-align: baseline;
height: 0.90rem;
margin-right: 0.20rem;
}
pre {
display: inline;
}
}
}
}
}
;

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="phone-alt" class="svg-inline--fa fa-phone-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M497.39 361.8l-112-48a24 24 0 0 0-28 6.9l-49.6 60.6A370.66 370.66 0 0 1 130.6 204.11l60.6-49.6a23.94 23.94 0 0 0 6.9-28l-48-112A24.16 24.16 0 0 0 122.6.61l-104 24A24 24 0 0 0 0 48c0 256.5 207.9 464 464 464a24 24 0 0 0 23.4-18.6l24-104a24.29 24.29 0 0 0-14.01-27.6z"></path></svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@ -93,6 +93,7 @@ Reset: 'Remise à zéro'
'%nb% person with similar name. Please verify that this is a new person': '{1} Une personne a un nom similaire. Vérifiez qu''il ne s''agit pas d''elle. | ]1, Inf] %nb% personnes ont un nom similaire. Vérifiez qu''il ne s''agit pas de l''une d''elles.'
'The person has been created': 'Le dossier a été créé'
'Person search results': 'Recherche de personnes'
Person search results by phonenumber: Recherche de personnes par numéro de téléphone
'Search within persons': 'Recherche parmi les personnes'
'%total% persons matching the search pattern:': '{0} Aucune personne ne correspond aux termes de recherche : | {1} Une personne a été trouvée par la recherche : | ]1,Inf] %total% personnes correspondent aux termes de recherche :'
'Last opening since %last_opening%': 'Dernière ouverture le %last_opening%.'

View File

@ -33,6 +33,7 @@
<th class="chill-red">{% trans %}Name{% endtrans %}</th>
<th class="chill-green">{% trans %}Date of birth{% endtrans %}</th>
<th class="chill-orange">{% trans %}Nationality{% endtrans %}</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
@ -63,6 +64,14 @@
{{ 'Without nationality'|trans }}
{% endif %}
</td>
<td>
<ul class="record_actions">
<li><a class="sc-button bt-show" href="{{ path('chill_person_view', { person_id : person.getId }) }}"></a></li>
{% if is_granted('CHILL_PERSON_UPDATE', person) %}
<li><a class="sc-button bt-update" href="{{ path('chill_person_general_edit', { person_id : person.getId }) }}"></a></li>
{% endif %}
</ul>
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,108 @@
{#
* Copyright (C) 2014, 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/>.
#}
<h2>{{ title|default('Person search results by phonenumber')|trans }}</h2>
<p>
{{ '%total% persons matching the search pattern:'|transchoice( total, { '%total%' : total}) }}
<a href="{{ path('chill_main_advanced_search', { "name": search_name, "q": pattern } ) }}" class="sc-button button-small">
<i class="fa fa-search" aria-hidden="true"></i> {{ pattern }}
</a>
</p>
<p>{{ 'Results %start%-%end% of %total%'|trans({ '%start%' : start, '%end%': start + persons|length, '%total%' : total } ) }}</p>
{% if persons|length > 0 %}
<table class="person-list__--by-phonenumber">
<thead>
<tr>
<th class="chill-red">{% trans %}Name{% endtrans %}</th>
<th class="chill-green">{% trans %}Date of birth{% endtrans %}</th>
<th class="chill-orange">{% trans %}Phonenumber{% endtrans %}</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for person in persons %}
<tr>
<td>
{% set is_open = person.isOpen() %}
<a href="{{ path('chill_person_view', { person_id : person.getId }) }}" {% if chill_person.fields.accompanying_period == 'visible' %}{% if is_open %} alt="{{ 'An accompanying period is open'|trans|e('html_attr') }}"{% else %} alt="{{ 'Any accompanying periods are open'|trans|e('html_attr') }}" {% endif %}{% endif %}>
{{person.firstName}} {{person.lastName}}
{% spaceless %}
{% if chill_person.fields.accompanying_period == 'visible' %}
{% if is_open == false %}
<i class="fa fa-folder" ></i>
{% else %}
<i class="fa fa-folder-open" ></i>
{% endif %}
{% endif %}
{% endspaceless %}
</a>
</td>
<td>
{% if person.birthdate is not null %}{{person.birthdate|localizeddate('long', 'none', app.request.locale) }}{% else %}{{ 'Unknown date of birth'|trans }}{% endif %}
</td>
<td class="person-list__--by-phonenumber__phones">
<ul>
{% if person.phonenumber is not empty %}
<li>
<a href="tel:{{ person.phonenumber }}"><img src="{{ asset('build/images/mobile-alt-solid.svg') }}">&nbsp;<pre>{{ person.phonenumber|chill_format_phonenumber }}</pre></a>
</li>
{% endif %}
{% if person.mobilenumber is not empty%}
<li>
<a href="tel:{{ person.mobilenumber }}"><img src="{{ asset('build/images/phone-alt-solid.svg') }}">&nbsp;<pre>{{ person.mobilenumber|chill_format_phonenumber }}</pre></a>
</li>
{% endif %}
</ul>
</td>
<td>
<ul class="record_actions">
<li><a class="sc-button bt-show" href="{{ path('chill_person_view', { person_id : person.getId }) }}"></a></li>
{% if is_granted('CHILL_PERSON_UPDATE', person) %}
<li><a class="sc-button bt-update" href="{{ path('chill_person_general_edit', { person_id : person.getId }) }}"></a></li>
{% endif %}
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="record_actions">
{% if is_granted('CHILL_PERSON_CREATE') %}
<li>
<a href="{{ path('chill_person_new') }}" class="sc-button bt-create">
{{ 'Add a person'|trans }}
</a>
</li>
{% endif %}
{% if preview == true and persons|length < total %}
<li>
<a href="{{ path('chill_main_search', { "name": search_name|default('abcd'), "q" : pattern }) }}" class="sc-button">
{{ 'See all results'|trans }}
</a>
</li>
{% endif %}
</ul>
{% endif %}
{% if preview == false %}
{{ chill_pagination(paginator) }}
{% endif %}

View File

@ -206,13 +206,13 @@ This view should receive those arguments:
{%- if chill_person.fields.phonenumber == 'visible' -%}
<dl>
<dt>{{ 'Phonenumber'|trans }}&nbsp;:</dt>
<dd>{% if person.phonenumber is not empty %}<pre>{{ person.phonenumber}}</pre>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
<dd>{% if person.phonenumber is not empty %}<a href="tel:{{ person.phonenumber }}"><pre>{{ person.phonenumber|chill_format_phonenumber }}</pre></a>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
</dl>
{% endif %}
{%- if chill_person.fields.mobilenumber == 'visible' -%}
<dl>
<dt>{{ 'Mobilenumber'|trans }}&nbsp;:</dt>
<dd>{% if person.mobilenumber is not empty %}<pre>{{ person.mobilenumber}}</pre>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
<dd>{% if person.mobilenumber is not empty %}<a href="tel:{{ person.mobilenumber }}"><pre>{{ person.mobilenumber|chill_format_phonenumber }}</pre></a>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
</dl>
{% endif %}
{%- if chill_person.fields.contact_info == 'visible' -%}

View File

@ -0,0 +1,133 @@
<?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

@ -1,4 +1,5 @@
// this file loads all assets from the Chill person bundle
require('./Resources/public/css/person.css');
require('./Resources/public/sass/index.js');
//require('./Resources/public/sass/person.scss');