Merge remote-tracking branch 'origin/master' into issue294_suggestPersonInActivity

This commit is contained in:
Julien Fastré 2021-11-22 14:06:27 +01:00
commit f3792b2ed6
110 changed files with 3491 additions and 1141 deletions

View File

@ -16,6 +16,30 @@ and this project adheres to
* [activity] suggest requestor, user and ressources for adding persons|user|3rdparty * [activity] suggest requestor, user and ressources for adding persons|user|3rdparty
* [calendar] suggest persons, professionals and invites for adding persons|3rdparty|user * [calendar] suggest persons, professionals and invites for adding persons|3rdparty|user
* [activity] take into account the restrictions on person|thirdparties|users visibilities defined in ActivityType * [activity] take into account the restrictions on person|thirdparties|users visibilities defined in ActivityType
* [main] Add currentLocation to the User entity + add a page for selecting this location + add in the user menu (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/133)
* [activity] add user current location as default location for a new activity (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/133)
* [task] Select2 field in task form to allow search for a user (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/167)
* remove "search by phone configuration option": search by phone is now executed by default
* remplacer le classement par ordre alphabétique par un classement par ordre de pertinence, qui tient compte:
* de la présence d'une string avec le nom de la ville;
* de la similarité;
* du fait que la recherche commence par une partie du mot recherché
* ajouter la recherche par numéro de téléphone directement dans la barre de recherche et dans le formulaire recherche avancée;
* ajouter la recherche par date de naissance directement dans la barre de recherche;
* ajouter la recherche par ville dans la recherche avancée
* ajouter un lien vers le ménage dans les résultats de recherche
* ajouter l'id du parcours dans les résultats de recherche
* ajouter le demandeur dans les résultats de recherche
* ajout d'un bouton "recherche avancée" sur la page d'accueil
* [person] create an accompanying course: add client-side validation if no origin (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/210)
* [person] fix bounds for computing current person address: the new address appears immediatly
* [docgen] create a normalizer and serializer for normalization on doc format
* [person normalization] the key center is now "centers" and is an array. Empty array if no center
## 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)
@ -30,10 +54,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)
## Test releases
### Test release 2021-11-08 ### Test release 2021-11-08

View File

@ -91,7 +91,8 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"App\\": "tests/app/src/" "App\\": "tests/app/src/",
"Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests"
} }
}, },
"minimum-stability": "dev", "minimum-stability": "dev",

View File

@ -23,15 +23,196 @@ Every time an entity is created, viewed or updated, the software check if the us
The user must be granted access to the action on this particular entity, with this scope and center. The user must be granted access to the action on this particular entity, with this scope and center.
TL;DR
=====
Resolve scope and center
------------------------
In a service, resolve the center and scope of an entity
.. code-block:: php
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher;
class MyService {
private ScopeResolverDispatcher $scopeResolverDispatcher;
private CenterResolverDispatcher $centerResolverDispatcher;
public function myFunction($entity) {
/** @var null|Center[]|Center $center */
$center = $this->centerResolverDispatcher->resolveCenter($entity);
// $center may be null, an array of center, or an instance of Center
if ($this->scopeResolverDispatcher->isConcerned($entity) {
/** @var null|Scope[]|Scope */
$scope = $this-scopeResolverDispatcher->resolveScope($entity);
// $scope may be null, an array of Scope, or an instance of Scope
}
}
}
In twig template, resolve the center:
.. code-block:: twig
{# resolve a center #}
{% if person|chill_resolve_center is not null%}
{% if person|chill_resolve_center is iterable %}
{% set centers = person|chill_resolve_center %}
{% else %}
{% set centers = [ person|chill_resolve_center ] %}
{% endif %}
<span class="open_sansbold">
{{ 'Center'|trans|upper}} :
</span>
{% for c in centers %}
{{ c.name|upper }}
{% if not loop.last %}, {% endif %}
{% endfor %}
{%- endif -%}
In twig template, resolve the scope:
.. code-block:: twig
{% if entity|chill_is_scope_concerned %}
{% if entity|chill_resolve_scope is iterable %}
{% set scopes = entity|chill_resolve_scope %}
{% else %}
{% set scopes = [ entity|chill_resolve_scope ] %}
{% endif %}
<span>Scopes&nbsp;:</span>
{% for s in scopes %}
{{ c.name|localize_translatable_string }}
{% if not loop.last %}, {% endif %}
{% endfor %}
{%- endif -%}
Build a ``Voter``
-----------------
.. code-block:: php
<?php
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface;
use Chill\MainBundle\Security\Authorization\VoterHelperInterface;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Security;
class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{
// roles should be stored into constants:
const CREATE = 'CHILL_PERSON_DOCUMENT_CREATE';
const SEE = 'CHILL_PERSON_DOCUMENT_SEE';
const SEE_DETAILS = 'CHILL_PERSON_DOCUMENT_SEE_DETAILS';
const UPDATE = 'CHILL_PERSON_DOCUMENT_UPDATE';
const DELETE = 'CHILL_PERSON_DOCUMENT_DELETE';
protected Security $security;
protected VoterHelperInterface $voterHelper;
public function __construct(
Security $security,
VoterHelperFactoryInterface $voterHelperFactory
) {
$this->security = $security;
// we build here a voter helper. This will ease the operations below.
// when the authorization model is changed, it will be easy to make a different implementation
// of the helper, instead of writing all Voters
$this->voterHelper = $voterHelperFactory
// create a builder with some context
->generate(self::class)
// add the support of given roles for given class:
->addCheckFor(Person::class, [self::SEE, self::CREATE])
->addCheckFor(PersonDocument::class, $this->getRoles())
->build();
}
protected function supports($attribute, $subject)
{
return $this->voterHelper->supports($attribute, $subject);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
// basic check
if (!$token->getUser() instanceof User) {
return false;
}
// we first check the acl for associated elements.
// here, we must be able to see the person associated to the document:
if ($subject instanceof PersonDocument
&& !$this->security->isGranted(PersonVoter::SEE, $subject->getPerson())) {
// not possible to see the associated person ? Then, not possible to see the document!
return false;
}
// the voter helper will implements the logic:
return $this->voterHelper->voteOnAttribute($attribute, $subject, $token);
}
// all the method below are used to register roles into the admin part
public function getRoles()
{
return [
self::CREATE,
self::SEE,
self::SEE_DETAILS,
self::UPDATE,
self::DELETE
];
}
public function getRolesWithoutScope()
{
return array();
}
public function getRolesWithHierarchy()
{
return ['PersonDocument' => $this->getRoles() ];
}
}
From an user point of view From an user point of view
-------------------------- ==========================
The software is design to allow fine tuned access rights for complicated installation and team structure. The administrators may also decide that every user has the right to see all resources, where team have a more simple structure. The software is design to allow fine tuned access rights for complicated installation and team structure. The administrators may also decide that every user has the right to see all resources, where team have a more simple structure.
Here is an overview of the model. Here is an overview of the model.
Chill can be multi-center Chill can be multi-center
^^^^^^^^^^^^^^^^^^^^^^^^^ -------------------------
Chill is designed to be installed once for social center who work with multiple teams separated, or for social services's federation who would like to share the same installation of the software for all their members. Chill is designed to be installed once for social center who work with multiple teams separated, or for social services's federation who would like to share the same installation of the software for all their members.
@ -42,7 +223,7 @@ Otherwise, it is not required to create multiple center: Chill can also work for
Obviously, users working in the different centers are not allowed to see the entities (_persons_, _reports_, _activities_) of other centers. But users may be attached to multiple centers: consequently they will be able to see the entities of the multiple centers they are attached to. Obviously, users working in the different centers are not allowed to see the entities (_persons_, _reports_, _activities_) of other centers. But users may be attached to multiple centers: consequently they will be able to see the entities of the multiple centers they are attached to.
Inside center, scope divide team Inside center, scope divide team
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ --------------------------------
Users are attached to one or more center and, inside to those center, there may exists differents scopes. The aim of those _scopes_ is to divide the whole team of social worker amongst different departement, for instance: the social team, the psychologist team, the nurse team, the administrative team, ... Each team is granted of different rights amongst scope. For instance, the social team may not see the _activities_ of the psychologist team. The administrative team may see the date & time's activities, but is not allowed to see the detail of those entities (the personal notes, ...). Users are attached to one or more center and, inside to those center, there may exists differents scopes. The aim of those _scopes_ is to divide the whole team of social worker amongst different departement, for instance: the social team, the psychologist team, the nurse team, the administrative team, ... Each team is granted of different rights amongst scope. For instance, the social team may not see the _activities_ of the psychologist team. The administrative team may see the date & time's activities, but is not allowed to see the detail of those entities (the personal notes, ...).
@ -52,8 +233,38 @@ As entities have only one scopes, if some entities must be shared across two dif
Example: if some activities must be seen and updated between nurses and psychologists, the administrator will create a scope "nurse and psy" and add the ability for both team "nurse" and "psychologist" to "create", "see", and "update" the activities belonging to scope "nurse and psy". Example: if some activities must be seen and updated between nurses and psychologists, the administrator will create a scope "nurse and psy" and add the ability for both team "nurse" and "psychologist" to "create", "see", and "update" the activities belonging to scope "nurse and psy".
Where does the ``scope`` and ``center`` comes from ?
====================================================
Most often, scope and center comes from user's input:
* when person is created, Chill asks the associated center to the user. Then, every entity associated to the user (Activity, ...) is associated to this center;
* when an entity is created, Chill asks the associated scope.
The UI check the model before adding those input into form. If the user hae access to only one center or scope, this scope or center is filled automatically, and the UI does not ask the user. Most of the times, the user does not see "Pick a scope" and "Pick a center" inputs.
Scope and Center are associated to entities through ``ManyToOne`` properties, which are then mapped to ``FOREIGN KEY`` in tables, ...
But sometimes, this implementation does not fits the needs:
* persons are associated to center *geographically*: the address of each person contains lat/lon coordinates, and the center is resolved from this coordinated;
* some would like to associated persons to multiple center, or one center;
* entities are associated to scope through the job reached by "creator" (an user);
* some would like not to use scope at all;
* …
For this reasons, associated center and scopes must be resolved programmatically. The default implementation rely on the model association, as described above. But it becomes possible to change the behaviour on different implementations.
Is my entity "concerned" by scopes ?
------------------------------------
Some entities are concerned by scope, some not.
This is also programmatically resolved.
The concepts translated into code The concepts translated into code
----------------------------------- ===================================
.. figure:: /_static/access_control_model.png .. figure:: /_static/access_control_model.png
@ -81,7 +292,7 @@ At each step of his lifetime (creation, view of the entity and eventually of his
All those action are executed through symfony voters and helpers. All those action are executed through symfony voters and helpers.
How to check authorization ? How to check authorization ?
---------------------------- ============================
Just use the symfony way-of-doing, but do not forget to associate the entity you want to check access. For instance, in controller : Just use the symfony way-of-doing, but do not forget to associate the entity you want to check access. For instance, in controller :
@ -100,34 +311,23 @@ Just use the symfony way-of-doing, but do not forget to associate the entity you
And in template : And in template :
.. code-block:: html+jinja .. code-block:: twig
{{ if is_granted('CHILL_ENTITY_SEE', entity) %}print something{% endif %} {{ if is_granted('CHILL_ENTITY_SEE', entity) %}print something{% endif %}
Retrieving reachable scopes and centers Retrieving reachable scopes and centers for a user
---------------------------------------- --------------------------------------------------
The class :class:`Chill\\MainBundle\\Security\\Authorization\\AuthorizationHelper` helps you to get centers and scope reachable by a user. The class :class:`Chill\\MainBundle\\Security\\Authorization\\AuthorizationHelperInterface` helps you to get centers and scope reachable by a user.
Those methods are intentionnaly build to give information about user rights: Those methods are intentionnaly build to give information about user rights:
- getReachableCenters: to get reachable centers for a user - getReachableCenters: to get reachable centers for a user
- getReachableScopes : to get reachable scopes for a user - getReachableScopes : to get reachable scopes for a user
.. note::
The service is reachable through the Depedency injection with the key `chill.main.security.authorization.helper`. Example :
.. code-block:: php
$helper = $container->get('chill.main.security.authorization.helper');
.. todo::
Waiting for a link between our api and this doc, we invite you to read the method signatures `here <https://github.com/Chill-project/Main/blob/add_acl/Security/Authorization/AuthorizationHelper.php>`_
Adding your own roles Adding your own roles
===================== ---------------------
Extending Chill will requires you to define your own roles and rules for your entities. You will have to define your own voter to do so. Extending Chill will requires you to define your own roles and rules for your entities. You will have to define your own voter to do so.
@ -152,7 +352,7 @@ To create your own roles, you should:
Declare your role Declare your role
------------------ ^^^^^^^^^^^^^^^^^^
To declare new role, implement the class :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`. To declare new role, implement the class :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`.
@ -212,69 +412,8 @@ Example of an implementation of :class:`Chill\\MainBundle\\Security\\ProvideRole
} }
Implement your voter
--------------------
Inside this class, you might use the :class:`Chill\\MainBundle\\Security\\Authorization\\AuthorizationHelper` to check permission (do not re-invent the wheel). This is a real-world example:
.. code-block:: php
namespace Chill\ReportBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
class ReportVoter extends AbstractChillVoter
{
const CREATE = 'CHILL_REPORT_CREATE';
const SEE = 'CHILL_REPORT_SEE';
const UPDATE = 'CHILL_REPORT_UPDATE';
/**
*
* @var AuthorizationHelper
*/
protected $helper;
public function __construct(AuthorizationHelper $helper)
{
$this->helper = $helper;
}
protected function getSupportedAttributes()
{
return array(self::CREATE, self::SEE, self::UPDATE);
}
protected function getSupportedClasses()
{
return array('Chill\ReportBundle\Entity\Report');
}
protected function isGranted($attribute, $report, $user = null)
{
if (! $user instanceof \Chill\MainBundle\Entity\User){
return false;
}
return $this->helper->userHasAccess($user, $report, $attribute);
}
}
Then, you will have to declare the service and tag it as a voter :
.. code-block:: yaml
services:
chill.report.security.authorization.report_voter:
class: Chill\ReportBundle\Security\Authorization\ReportVoter
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
Adding role hierarchy Adding role hierarchy
--------------------- ^^^^^^^^^^^^^^^^^^^^^
You should prepend Symfony's security component directly from your code. You should prepend Symfony's security component directly from your code.
@ -312,3 +451,484 @@ You should prepend Symfony's security component directly from your code.
} }
Implement your voter
^^^^^^^^^^^^^^^^^^^^
Most of the time, Voter will check that:
1. The given role is reachable (= ``$attribute``)
2. for the given center,
3. and, if any, for the given role
4. if the entity is associated to another entity, this entity should be, at least, viewable by the user.
Thats what we call the "autorization logic". But this logic may be replace by a new one, and developers should take care of it.
Then voter implementation should take care of:
* check the access to associated entities. For instance, if an ``Activity`` is associated to a ``Person``, the voter should first check that the user can show the associated ``Person``;
* as far as possible, delegates the check for associated center, scopes, and check for authorization using the authorization logic. VoterHelper will ease the most common operation of this logic.
This is an example of implementation:
.. code-block:: php
<?php
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface;
use Chill\MainBundle\Security\Authorization\VoterHelperInterface;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{
protected Security $security;
protected VoterHelperInterface $voterHelper;
public function __construct(
Security $security,
VoterHelperFactoryInterface $voterHelperFactory
) {
$this->security = $security;
// we build here a voter helper. This will ease the operations below.
// when the authorization model is changed, it will be easy to make a different implementation
// of the helper, instead of writing all Voters
$this->voterHelper = $voterHelperFactory
// create a builder with some context
->generate(self::class)
// add the support of given roles for given class:
->addCheckFor(Person::class, [self::SEE, self::CREATE])
->addCheckFor(PersonDocument::class, $this->getRoles())
->build();
}
protected function supports($attribute, $subject)
{
return $this->voterHelper->supports($attribute, $subject);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
// basic check
if (!$token->getUser() instanceof User) {
return false;
}
// we first check the acl for associated elements.
// here, we must be able to see the person associated to the document:
if ($subject instanceof PersonDocument
&& !$this->security->isGranted(PersonVoter::SEE, $subject->getPerson())) {
// not possible to see the associated person ? Then, not possible to see the document!
return false;
}
// the voter helper will implements the logic of checking:
// 1. that the center is reachable
// 2. for this given entity
// 3. for this given scope
// 4. and for the given role
return $this->voterHelper->voteOnAttribute($attribute, $subject, $token);
}
public function getRoles()
{
// ...
}
public function getRolesWithoutScope()
{
// ...
}
public function getRolesWithHierarchy()
{
// ...
}
}
Then, you will have to declare the service and tag it as a voter :
.. code-block:: yaml
services:
chill.report.security.authorization.report_voter:
class: Chill\ReportBundle\Security\Authorization\ReportVoter
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
How to resolve scope and center programmatically ?
==================================================
In a service, resolve the center and scope of an entity
.. code-block:: php
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher;
class MyService {
private ScopeResolverDispatcher $scopeResolverDispatcher;
private CenterResolverDispatcher $centerResolverDispatcher;
public function myFunction($entity) {
/** @var null|Center[]|Center $center */
$center = $this->centerResolverDispatcher->resolveCenter($entity);
// $center may be null, an array of center, or an instance of Center
if ($this->scopeResolverDispatcher->isConcerned($entity) {
/** @var null|Scope[]|Scope */
$scope = $this-scopeResolverDispatcher->resolveScope($entity);
// $scope may be null, an array of Scope, or an instance of Scope
}
}
}
In twig template, resolve the center:
.. code-block:: twig
{# resolve a center #}
{% if person|chill_resolve_center is not null%}
{% if person|chill_resolve_center is iterable %}
{% set centers = person|chill_resolve_center %}
{% else %}
{% set centers = [ person|chill_resolve_center ] %}
{% endif %}
<span class="open_sansbold">
{{ 'Center'|trans|upper}} :
</span>
{% for c in centers %}
{{ c.name|upper }}
{% if not loop.last %}, {% endif %}
{% endfor %}
{%- endif -%}
In twig template, resolve the scope:
.. code-block:: twig
{% if entity|chill_is_scope_concerned %}
{% if entity|chill_resolve_scope is iterable %}
{% set scopes = entity|chill_resolve_scope %}
{% else %}
{% set scopes = [ entity|chill_resolve_scope ] %}
{% endif %}
<span>Scopes&nbsp;:</span>
{% for s in scopes %}
{{ c.name|localize_translatable_string }}
{% if not loop.last %}, {% endif %}
{% endfor %}
{%- endif -%}
What is the default implementation of Scope and Center resolver ?
-----------------------------------------------------------------
By default, the implementation rely on association into entities.
* implements ``Chill\MainBundle\Entity\HasCenterInterface`` on entities which have one or any center;
* implements ``Chill\MainBundle\Entity\HasCentersInterface`` on entities which have one, multiple or any centers;
* implements ``Chill\MainBundle\Entity\HasScopeInterface`` on entities which have one or any scope;
* implements ``Chill\MainBundle\Entity\HasScopesInterface`` on entities which have one or any scopes;
Then, the default implementation will resolve the center and scope based on the implementation in your model.
How to change the default behaviour ?
-------------------------------------
Implements those interface into services:
* ``Chill\MainBundle\Security\Resolver\CenterResolverInterface``;
* ``Chill\MainBundle\Security\Resolver\ScopeResolverInterface``;
Authorization into lists and index pages
========================================
Due to the fact that authorization model may be overriden, "list" and "index" pages should not rely on center and scope from controller. This must be delegated to dedicated service, which will be aware of the authorization model. We call them ``ACLAwareRepository``. This service must implements an interface, in order to allow to change the implementation.
The controller **must not** performs any DQL or SQL query.
Example in a controller:
.. code-block:: php
namespace Chill\TaskBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface;
final class SingleTaskController extends AbstractController
{
private SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository;
/**
*
* @Route(
* "/{_locale}/task/single-task/list",
* name="chill_task_singletask_list"
* )
*/
public function listAction(
Request $request
) {
$this->denyAccessUnlessGranted(TaskVoter::SHOW, null);
$nb = $this->singleTaskAclAwareRepository->countByAllViewable(
'', // search pattern
[] // search flags
);
$paginator = $this->paginatorFactory->create($nb);
if (0 < $nb) {
$tasks = $this->singleTaskAclAwareRepository->findByAllViewable(
'', // search pattern
[] // search flags
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage(),
// ordering:
[
'startDate' => 'DESC',
'endDate' => 'DESC',
]
);
} else {
$tasks = [];
}
return $this->render('@ChillTask/SingleTask/List/index.html.twig', [
'tasks' => $tasks,
'paginator' => $paginator,
'filter_order' => $filterOrder
]);
}
}
Writing ``ACLAwareRepository``
------------------------------
The ACLAwareRepository should rely on interfaces
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
As described above, the ACLAwareRepository will perform the query for listing entities, and take care of authorization.
Those "ACLAwareRepositories" must be described into ``interfaces``.
The service must rely on this interface, and not on the default implementation.
Example: at first, we design an interface for listing ``SingleTask`` entities:
.. code-block:: php
<?php
namespace Chill\TaskBundle\Repository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
interface SingleTaskAclAwareRepositoryInterface
{
/**
* @return SingleTask[]|array
*/
public function findByCurrentUsersTasks(?string $pattern = null, ?array $flags = [], ?int $start = 0, ?int $limit = 50, ?array $orderBy = []): array;
public function countByCurrentUsersTasks(?string $pattern = null, ?array $flags = []): int;
public function countByAllViewable(
?string $pattern = null,
?array $flags = []
): int;
/**
* @return SingleTask[]|array
*/
public function findByAllViewable(
?string $pattern = null,
?array $flags = [],
?int $start = 0,
?int $limit = 50,
?array $orderBy = []
): array;
}
Implements this interface and register the interface as an alias for the implementation.
.. code-block:: yaml
services:
Chill\TaskBundle\Repository\SingleTaskAclAwareRepository:
autowire: true
autoconfigure: true
Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface: '@Chill\TaskBundle\Repository\SingleTaskAclAwareRepository'
Write the basic implementation for re-use: separate authorization logic and search logic
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The logic of such repository may be separated into two logic:
* the authorization logic (show only entities that the user is allowed to see);
* the search logic (filter entities on some criterias).
This logic should be separated into your implementation.
Considering this simple interface:
.. code-block:: php
interface MyEntityACLAwareRepositoryInterface {
public function countByAuthorized(array $criterias): int;
public function findByAuthorized(array $criteria, int $start, int $limit, array $orderBy): array;
}
The base implementation should separate the logic to allow an easy reuse. Here, the method ``buildQuery`` build a basic query without authorization logic, which can be re-used. The authorization logic is dedicated to a private method. For ease of user, the logic of adding ordering criterias and pagination parameters (``$start`` and ``$limit``) are also delegated to a public method.
.. code-block:: php
namespace Chill\MyBundle\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
final class MyEntityACLAwareRepository implements MyEntityACLAwareRepositoryInterface {
private EntityManagerInterface $em;
// constructor omitted
public function countByAuthorized(array $criterias): int
{
$qb = $this->buildQuery($criterias);
return $this->addAuthorizations($qb)->select("COUNT(e)")->getQuery()->getResult()->getSingleScalarResult();
}
public function findByAuthorized(array $criteria, int $start, int $limit, array $orderBy): array
{
$qb = $this->buildQuery($criterias);
return $this->getResult($this->addAuthorizations($qb), $start, $limit, $orderBy);
}
public function getResult(QueryBuilder $qb, int $start, int $limit, array $orderBy): array
{
$qb
->setFirstResult($start)
->setMaxResults($limit)
;
// add order by logic
return $qb->getQuery()->getResult();
}
public function buildQuery(array $criterias): QueryBuilder
{
$qb = $this->em->createQueryBuilder();
// implement you logic with search criteria here
return $qb;
}
private function addAuthorizations(QueryBuilder $qb): QueryBuilder
{
// add authorization logic here
return $qb;
}
}
Once this logic is executed, it becomes easy to make a new implementation of the repository:
.. code-block:: php
namespace Chill\MyOtherBundle\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Chill\MyBundle\Repository\MyEntityACLAwareRepository
final class AnotherEntityACLAwareRepository implements MyEntityACLAwareRepositoryInterface {
private EntityManagerInterface $em;
private \Chill\MyBundle\Repository\MyEntityACLAwareRepository $initial;
public function __construct(
EntityManagerInterface $em,
\Chill\MyBundle\Repository\MyEntityACLAwareRepository $initial
) {
$this->em = $em;
$this->initial = $initial;
}
public function countByAuthorized(array $criterias): int
{
$qb = $this->initial->buildQuery($criterias);
return $this->addAuthorizations($qb)->select("COUNT(e)")->getQuery()->getResult()->getSingleScalarResult();
}
public function findByAuthorized(array $criteria, int $start, int $limit, array $orderBy): array
{
$qb = $this->initial->buildQuery($criterias);
return $this->initial->getResult($this->addAuthorizations($qb), $start, $limit, $orderBy);
}
private function addAuthorizations(QueryBuilder $qb): QueryBuilder
{
// add a different authorization logic here
return $qb;
}
}
Then, register this service and decorates the old one:
.. code-block:: yaml
services:
Chill\MyOtherBundle\Repository\AnotherEntityACLAwareRepository:
autowire: true
autoconfigure: true
decorates: Chill\MyBundle\Repository\MyEntityACLAwareRepositoryInterface:

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

@ -4,6 +4,7 @@ parameters:
- src/ - src/
excludePaths: excludePaths:
- src/Bundle/*/Tests/* - src/Bundle/*/Tests/*
- src/Bundle/*/tests/*
- src/Bundle/*/Test/* - src/Bundle/*/Test/*
- src/Bundle/*/config/* - src/Bundle/*/config/*
- src/Bundle/*/migrations/* - src/Bundle/*/migrations/*

View File

@ -37,6 +37,9 @@
<testsuite name="CalendarBundle"> <testsuite name="CalendarBundle">
<directory suffix="Test.php">src/Bundle/ChillCalendarBundle/Tests/</directory> <directory suffix="Test.php">src/Bundle/ChillCalendarBundle/Tests/</directory>
</testsuite> </testsuite>
<testsuite name="DocGeneratorBundle">
<directory suffix="Test.php">src/Bundle/ChillDocGeneratorBundle/tests/</directory>
</testsuite>
</testsuites> </testsuites>
<listeners> <listeners>

View File

@ -298,16 +298,19 @@ final class ActivityController extends AbstractController
$activity_array = $this->serializer->normalize($entity, 'json', ['groups' => 'read']); $activity_array = $this->serializer->normalize($entity, 'json', ['groups' => 'read']);
$defaultLocationId = $this->getUser()->getCurrentLocation()->getId();
return $this->render($view, [ return $this->render($view, [
'person' => $person, 'person' => $person,
'accompanyingCourse' => $accompanyingPeriod, 'accompanyingCourse' => $accompanyingPeriod,
'entity' => $entity, 'entity' => $entity,
'form' => $form->createView(), 'form' => $form->createView(),
'activity_json' => $activity_array 'activity_json' => $activity_array,
'default_location_id' => $defaultLocationId
]); ]);
} }
public function showAction(Request $request, $id): Response public function showAction(Request $request, int $id): Response
{ {
$view = null; $view = null;
@ -361,7 +364,7 @@ final class ActivityController extends AbstractController
/** /**
* Displays a form to edit an existing Activity entity. * Displays a form to edit an existing Activity entity.
*/ */
public function editAction($id, Request $request): Response public function editAction(int $id, Request $request): Response
{ {
$view = null; $view = null;

View File

@ -36,6 +36,8 @@ use Chill\MainBundle\DataFixtures\ORM\LoadScopes;
class LoadActivity extends AbstractFixture implements OrderedFixtureInterface class LoadActivity extends AbstractFixture implements OrderedFixtureInterface
{ {
use \Symfony\Component\DependencyInjection\ContainerAwareTrait;
/** /**
* @var \Faker\Generator * @var \Faker\Generator
*/ */
@ -80,15 +82,10 @@ class LoadActivity extends AbstractFixture implements OrderedFixtureInterface
* *
* @return \Chill\ActivityBundle\Entity\ActivityReason * @return \Chill\ActivityBundle\Entity\ActivityReason
*/ */
private function getRandomActivityReason(array $excludingIds) private function getRandomActivityReason()
{ {
$reasonRef = LoadActivityReason::$references[array_rand(LoadActivityReason::$references)]; $reasonRef = LoadActivityReason::$references[array_rand(LoadActivityReason::$references)];
if (in_array($this->getReference($reasonRef)->getId(), $excludingIds, true)) {
// we have a reason which should be excluded. Find another...
return $this->getRandomActivityReason($excludingIds);
}
return $this->getReference($reasonRef); return $this->getReference($reasonRef);
} }
@ -103,7 +100,7 @@ class LoadActivity extends AbstractFixture implements OrderedFixtureInterface
return $this->getReference($userRef); return $this->getReference($userRef);
} }
public function newRandomActivity($person) public function newRandomActivity($person): ?Activity
{ {
$activity = (new Activity()) $activity = (new Activity())
->setUser($this->getRandomUser()) ->setUser($this->getRandomUser())
@ -116,11 +113,13 @@ class LoadActivity extends AbstractFixture implements OrderedFixtureInterface
// ->setAttendee($this->faker->boolean()) // ->setAttendee($this->faker->boolean())
$usedId = array();
for ($i = 0; $i < rand(0, 4); $i++) { for ($i = 0; $i < rand(0, 4); $i++) {
$reason = $this->getRandomActivityReason($usedId); $reason = $this->getRandomActivityReason();
$usedId[] = $reason->getId(); if (null !== $reason) {
$activity->addReason($reason); $activity->addReason($reason);
} else {
return null;
}
} }
return $activity; return $activity;
@ -137,7 +136,9 @@ class LoadActivity extends AbstractFixture implements OrderedFixtureInterface
for ($i = 0; $i < $activityNbr; $i ++) { for ($i = 0; $i < $activityNbr; $i ++) {
$activity = $this->newRandomActivity($person); $activity = $this->newRandomActivity($person);
$manager->persist($activity); if (null !== $activity) {
$manager->persist($activity);
}
} }
} }
$manager->flush(); $manager->flush();

View File

@ -10,9 +10,12 @@
</persons-bloc> </persons-bloc>
</div> </div>
<div v-if="getContext === 'accompanyingCourse' && suggestedEntities.length > 0"> <div v-if="getContext === 'accompanyingCourse' && suggestedEntities.length > 0">
<ul> <ul class="list-unstyled">
<li v-for="p in suggestedEntities" @click="addSuggestedEntity(p)"> <li v-for="p in suggestedEntities" @click="addSuggestedEntity(p)">
{{ p.text }} <span class="badge bg-primary" style="cursor: pointer;">
<i class="fa fa-plus fa-fw text-success"></i>
{{ p.text }}
</span>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -4,7 +4,7 @@
<span class="chill_denomination"> <span class="chill_denomination">
{{ textCutted }} {{ textCutted }}
</span> </span>
<a class="fa fa-fw fa-times" <a class="fa fa-fw fa-times text-danger text-decoration-none"
@click.prevent="$emit('remove', person)"> @click.prevent="$emit('remove', person)">
</a> </a>
</span> </span>

View File

@ -55,16 +55,17 @@ export default {
} }
}, },
mounted() { mounted() {
this.getLocationsList(); getLocations().then(response => new Promise(resolve => {
console.log('getLocations', response);
this.locations = response.results;
if (window.default_location_id) {
let location = this.locations.filter(l => l.id === window.default_location_id);
this.$store.dispatch('updateLocation', location);
}
resolve();
}))
}, },
methods: { methods: {
getLocationsList() {
getLocations().then(response => new Promise(resolve => {
console.log('getLocations', response);
this.locations = response.results;
resolve();
}))
},
customLabel(value) { customLabel(value) {
return `${value.locationType.title.fr} ${value.name ? value.name : ''}`; return `${value.locationType.title.fr} ${value.name ? value.name : ''}`;
} }

View File

@ -55,6 +55,9 @@ const store = createStore({
.filter((p) => !existingPersonIds.includes(p.id)); .filter((p) => !existingPersonIds.includes(p.id));
}, },
suggestedRequestor(state) { suggestedRequestor(state) {
if (state.activity.accompanyingPeriod.requestor === null) {
return [];
}
const existingPersonIds = state.activity.persons.map((p) => p.id); const existingPersonIds = state.activity.persons.map((p) => p.id);
const existingThirdPartyIds = state.activity.thirdParties.map( const existingThirdPartyIds = state.activity.thirdParties.map(
(p) => p.id (p) => p.id
@ -74,7 +77,7 @@ const store = createStore({
return state.activity.activityType.usersVisible === 0 return state.activity.activityType.usersVisible === 0
? [] ? []
: [state.activity.accompanyingPeriod.user].filter( : [state.activity.accompanyingPeriod.user].filter(
(u) => !existingUserIds.includes(u.id) (u) => u !== null && !existingUserIds.includes(u.id)
); );
}, },
suggestedResources(state) { suggestedResources(state) {

View File

@ -27,14 +27,16 @@
{{ activity.type.name | localize_translatable_string }} {{ activity.type.name | localize_translatable_string }}
<ul class="small_in_title"> <ul class="small_in_title">
{% if activity.location and t.locationVisible %}
<li> <li>
<abbr title="{{ 'location'|trans }}">{{ 'location'|trans ~ ': ' }}</abbr> <span class="item-key">{{ 'location'|trans ~ ': ' }}</span>
{# TODO {% if activity.location %}{{ activity.location }}{% endif %} #} <span>{{ activity.location.locationType.title|localize_translatable_string }}</span>
Domicile de l'usager {{ activity.location.name }}
</li> </li>
{% endif %}
{% if activity.user and t.userVisible %} {% if activity.user and t.userVisible %}
<li> <li>
<abbr title="{{ 'Referrer'|trans }}">{{ 'Referrer'|trans ~ ': ' }}</abbr> <span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span>
{{ activity.user.usernameCanonical }} {{ activity.user.usernameCanonical }}
</li> </li>
{% endif %} {% endif %}

View File

@ -22,6 +22,7 @@
'{{ "You are going to leave a page with unsubmitted data. Are you sure you want to leave ?"|trans }}'); '{{ "You are going to leave a page with unsubmitted data. Are you sure you want to leave ?"|trans }}');
}); });
window.activity = {{ activity_json|json_encode|raw }}; window.activity = {{ activity_json|json_encode|raw }};
window.default_location_id = {{ default_location_id }};
</script> </script>
{{ encore_entry_script_tags('vue_activity') }} {{ encore_entry_script_tags('vue_activity') }}
{% endblock %} {% endblock %}

View File

@ -55,7 +55,7 @@
{% endif %} {% endif %}
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> <h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2>
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'with_display': 'bloc' } %} {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'with_display': 'bloc', 'badge_person': 'true' } %}
<h2 class="chill-red">{{ 'Activity data'|trans }}</h2> <h2 class="chill-red">{{ 'Activity data'|trans }}</h2>

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,69 @@
<?php
namespace Chill\DocGeneratorBundle\Serializer\Encoder;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
class DocGenEncoder implements \Symfony\Component\Serializer\Encoder\EncoderInterface
{
/**
* @inheritDoc
*/
public function encode($data, string $format, array $context = [])
{
if (!$this->isAssociative($data)) {
throw new UnexpectedValueException("Only associative arrays are allowed; lists are not allowed");
}
$result = [];
$this->recusiveEncoding($data, $result, '');
return $result;
}
private function recusiveEncoding(array $data, array &$result, $path)
{
if ($this->isAssociative($data)) {
foreach ($data as $key => $value) {
if (\is_array($value)) {
$this->recusiveEncoding($value, $result, $this->canonicalizeKey($path, $key));
} else {
$result[$this->canonicalizeKey($path, $key)] = $value;
}
}
} else {
foreach ($data as $elem) {
if (!$this->isAssociative($elem)) {
throw new UnexpectedValueException(sprintf("Embedded loops are not allowed. See data under %s path", $path));
}
$sub = [];
$this->recusiveEncoding($elem, $sub, '');
$result[$path][] = $sub;
}
}
}
private function canonicalizeKey(string $path, string $key): string
{
return $path === '' ? $key : $path.'_'.$key;
}
private function isAssociative(array $data)
{
$keys = \array_keys($data);
return $keys !== \array_keys($keys);
}
/**
* @inheritDoc
*/
public function supportsEncoding(string $format)
{
return $format === 'docgen';
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Chill\DocGeneratorBundle\Serializer\Helper;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class NormalizeNullValueHelper
{
private NormalizerInterface $normalizer;
public function __construct(NormalizerInterface $normalizer)
{
$this->normalizer = $normalizer;
}
public function normalize(array $attributes, string $format = 'docgen', ?array $context = [])
{
$data = [];
foreach ($attributes as $key => $class) {
if (is_numeric($key)) {
$data[$class] = '';
} else {
switch ($class) {
case 'array':
case 'bool':
case 'double':
case 'float':
case 'int':
case 'resource':
case 'string':
case 'null':
$data[$key] = '';
break;
default:
$data[$key] = $this->normalizer->normalize(null, $format, \array_merge(
$context,
['docgen:expects' => $class]
));
break;
}
}
}
return $data;
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace Chill\DocGeneratorBundle\Serializer\Normalizer;
use Chill\DocGeneratorBundle\Serializer\Helper\NormalizeNullValueHelper;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
use Symfony\Component\Serializer\Mapping\ClassMetadata;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class DocGenObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
private ClassMetadataFactoryInterface $classMetadataFactory;
private PropertyAccessor $propertyAccess;
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory)
{
$this->classMetadataFactory = $classMetadataFactory;
$this->propertyAccess = PropertyAccess::createPropertyAccessor();
}
/**
* @inheritDoc
*/
public function normalize($object, string $format = null, array $context = [])
{
$classMetadataKey = $object ?? $context['docgen:expects'];
if (!$this->classMetadataFactory->hasMetadataFor($classMetadataKey)) {
throw new LogicException(sprintf("This object does not have metadata: %s. Add groups on this entity to allow to serialize with the format %s and groups %s", is_object($object) ? get_class($object) : $context['docgen:expects'], $format, \implode(', ', $context['groups'])));
}
$metadata = $this->classMetadataFactory->getMetadataFor($classMetadataKey);
$expectedGroups = \array_key_exists(AbstractNormalizer::GROUPS, $context) ?
\is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]]
: [];
$attributes = \array_filter(
$metadata->getAttributesMetadata(),
function (AttributeMetadata $a) use ($expectedGroups) {
foreach ($a->getGroups() as $g) {
if (\in_array($g, $expectedGroups, true)) {
return true;
}
}
return false;
});
if (null === $object) {
return $this->normalizeNullData($format, $context, $metadata, $attributes);
}
return $this->normalizeObject($object, $format, $context, $expectedGroups, $metadata, $attributes);
}
/**
* @param string $format
* @param array $context
* @param array $expectedGroups
* @param ClassMetadata $metadata
* @param array|AttributeMetadata[] $attributes
*/
private function normalizeNullData(string $format, array $context, ClassMetadata $metadata, array $attributes): array
{
$keys = [];
foreach ($attributes as $attribute) {
$key = $attribute->getSerializedName() ?? $attribute->getName();
$keys[$key] = $this->getExpectedType($attribute, $metadata->getReflectionClass());
}
$normalizer = new NormalizeNullValueHelper($this->normalizer);
return $normalizer->normalize($keys, $format, $context);
}
/**
* @param $object
* @param $format
* @param array $context
* @param array $expectedGroups
* @param ClassMetadata $metadata
* @param array|AttributeMetadata[] $attributes
* @return array
* @throws ExceptionInterface
*/
private function normalizeObject($object, $format, array $context, array $expectedGroups, ClassMetadata $metadata, array $attributes)
{
$data = [];
$reflection = $metadata->getReflectionClass();
foreach ($attributes as $attribute) {
/** @var AttributeMetadata $attribute */
$value = $this->propertyAccess->getValue($object, $attribute->getName());
$key = $attribute->getSerializedName() ?? $attribute->getName();
if (is_object($value)) {
$data[$key] =
$this->normalizer->normalize($value, $format, \array_merge(
$context, $attribute->getNormalizationContextForGroups($expectedGroups)
));
} elseif (null === $value) {
$data[$key] = $this->normalizeNullOutputValue($format, $context, $attribute, $reflection);
} else {
$data[$key] = (string) $value;
}
}
return $data;
}
private function getExpectedType(AttributeMetadata $attribute, \ReflectionClass $reflection): string
{
// we have to get the expected content
if ($reflection->hasProperty($attribute->getName())) {
$type = $reflection->getProperty($attribute->getName())->getType();
} elseif ($reflection->hasMethod($attribute->getName())) {
$type = $reflection->getMethod($attribute->getName())->getReturnType();
} else {
throw new \LogicException(sprintf(
"Could not determine how the content is determined for the attribute %s. Add attribute property only on property or method", $attribute->getName()
));
}
if (null === $type) {
throw new \LogicException(sprintf(
"Could not determine the type for this attribute: %s. Add a return type to the method or property declaration", $attribute->getName()
));
}
return $type->getName();
}
/**
*/
private function normalizeNullOutputValue($format, array $context, AttributeMetadata $attribute, \ReflectionClass $reflection)
{
$type = $this->getExpectedType($attribute, $reflection);
switch ($type) {
case 'array':
case 'bool':
case 'double':
case 'float':
case 'int':
case 'resource':
case 'string':
return '';
default:
return $this->normalizer->normalize(
null,
$format,
\array_merge(
$context,
['docgen:expects' => $type]
)
);
}
}
/**
* @inheritDoc
*/
public function supportsNormalization($data, string $format = null): bool
{
return $format === 'docgen' && (is_object($data) || null === $data);
}
}

View File

@ -8,3 +8,10 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
resource: '../Repository/' resource: '../Repository/'
Chill\DocGeneratorBundle\Serializer\Normalizer\:
autowire: true
autoconfigure: true
resource: '../Serializer/Normalizer/'
tags:
- { name: 'serializer.normalizer', priority: -152 }

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,113 @@
<?php
namespace Chill\DocGeneratorBundle\Tests\Serializer\Encoder;
use Chill\DocGeneratorBundle\Serializer\Encoder\DocGenEncoder;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
class DocGenEncoderTest extends TestCase
{
private DocGenEncoder $encoder;
protected function setUp()
{
parent::setUp();
$this->encoder = new DocGenEncoder();
}
/**
* @dataProvider generateEncodeData
*/
public function testEncode($expected, $data, string $msg)
{
$generated = $this->encoder->encode($data, 'docgen');
$this->assertEquals($expected, $generated, $msg);
}
public function testEmbeddedLoopsThrowsException()
{
$this->expectException(UnexpectedValueException::class);
$data = [
'data' => [
['item' => 'one'],
[
'embedded' => [
[
['subitem' => 'two'],
['subitem' => 'three']
]
]
],
]
];
$this->encoder->encode($data, 'docgen');
}
public function generateEncodeData()
{
yield [ ['tests' => 'ok'], ['tests' => 'ok'], "A simple test with a simple array"];
yield [
// expected:
['item_subitem' => 'value'],
// data:
['item' => ['subitem' => 'value']],
"A test with multidimensional array"
];
yield [
// expected:
[ 'data' => [['item' => 'one'], ['item' => 'two']] ],
// data:
[ 'data' => [['item' => 'one'], ['item' => 'two']] ],
"a list of items"
];
yield [
// expected:
[ 'data' => [['item_subitem' => 'alpha'], ['item' => 'two']] ],
// data:
[ 'data' => [['item' => ['subitem' => 'alpha']], ['item' => 'two'] ] ],
"a list of items with multidimensional array inside item"
];
yield [
// expected:
[
'persons' => [
[
'firstname' => 'Jonathan',
'lastname' => 'Dupont',
'dateOfBirth_long' => '16 juin 1981',
'dateOfBirth_short' => '16/06/1981',
'father_firstname' => 'Marcel',
'father_lastname' => 'Dupont',
'father_dateOfBirth_long' => '10 novembre 1953',
'father_dateOfBirth_short' => '10/11/1953'
],
]
],
// data:
[
'persons' => [
[
'firstname' => 'Jonathan',
'lastname' => 'Dupont',
'dateOfBirth' => [ 'long' => '16 juin 1981', 'short' => '16/06/1981'],
'father' => [
'firstname' => 'Marcel',
'lastname' => 'Dupont',
'dateOfBirth' => ['long' => '10 novembre 1953', 'short' => '10/11/1953']
]
],
]
],
"a longer list, with near real data inside and embedded associative arrays"
];
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace Chill\DocGeneratorBundle\tests\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class DocGenObjectNormalizerTest extends KernelTestCase
{
private NormalizerInterface $normalizer;
protected function setUp()
{
parent::setUp();
self::bootKernel();
$this->normalizer = self::$container->get(NormalizerInterface::class);
}
public function testNormalizationBasic()
{
$user = new User();
$user->setUsername('User Test');
$user->setMainCenter($center = new Center());
$center->setName('test');
$normalized = $this->normalizer->normalize($user, 'docgen', [ AbstractNormalizer::GROUPS => ['docgen:read']]);
$expected = [
'label' => 'User Test',
'email' => '',
'mainCenter' => [
'name' => 'test'
]
];
$this->assertEquals($expected, $normalized, "test normalization fo an user");
}
public function testNormalizeWithNullValueEmbedded()
{
$user = new User();
$user->setUsername('User Test');
$normalized = $this->normalizer->normalize($user, 'docgen', [ AbstractNormalizer::GROUPS => ['docgen:read']]);
$expected = [
'label' => 'User Test',
'email' => '',
'mainCenter' => [
'name' => ''
]
];
$this->assertEquals($expected, $normalized, "test normalization fo an user with null center");
}
public function testNormalizeNullObjectWithObjectEmbedded()
{
$normalized = $this->normalizer->normalize(null, 'docgen', [
AbstractNormalizer::GROUPS => ['docgen:read'],
'docgen:expects' => User::class,
]);
$expected = [
'label' => '',
'email' => '',
'mainCenter' => [
'name' => ''
]
];
$this->assertEquals($expected, $normalized, "test normalization for a null user");
}
}

View File

@ -1,44 +1,19 @@
<?php <?php
/*
* Copyright (C) 2018 Champs-Libres SCRLFS
*
* 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\DocStoreBundle\Security\Authorization; namespace Chill\DocStoreBundle\Security\Authorization;
use App\Security\Authorization\VoterHelperFactory;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter; use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface; use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface;
use Chill\MainBundle\Security\Authorization\VoterHelperInterface; use Chill\MainBundle\Security\Authorization\VoterHelperInterface;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\DocStoreBundle\Entity\PersonDocument; use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Role\Role;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
/**
*
*/
class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{ {
const CREATE = 'CHILL_PERSON_DOCUMENT_CREATE'; const CREATE = 'CHILL_PERSON_DOCUMENT_CREATE';

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

@ -18,6 +18,7 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Entity\GroupCenter; use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Form\Type\ComposedGroupCenterType; use Chill\MainBundle\Form\Type\ComposedGroupCenterType;
use Chill\MainBundle\Form\UserCurrentLocationType;
use Chill\MainBundle\Form\UserPasswordType; use Chill\MainBundle\Form\UserPasswordType;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@ -87,7 +88,7 @@ class UserController extends CRUDController
[ [
'add_groupcenter_form' => $this->createAddLinkGroupCenterForm($entity, $request)->createView(), 'add_groupcenter_form' => $this->createAddLinkGroupCenterForm($entity, $request)->createView(),
'delete_groupcenter_form' => array_map( 'delete_groupcenter_form' => array_map(
function(\Symfony\Component\Form\Form $form) { function (\Symfony\Component\Form\Form $form) {
return $form->createView(); return $form->createView();
}, },
iterator_to_array($this->getDeleteLinkGroupCenterByUser($entity, $request), true) iterator_to_array($this->getDeleteLinkGroupCenterByUser($entity, $request), true)
@ -164,7 +165,7 @@ class UserController extends CRUDController
} }
$groupCenter = $em->getRepository('ChillMainBundle:GroupCenter') $groupCenter = $em->getRepository('ChillMainBundle:GroupCenter')
->find($gcid); ->find($gcid);
if (!$groupCenter) { if (!$groupCenter) {
throw $this->createNotFoundException('Unable to find groupCenter entity'); throw $this->createNotFoundException('Unable to find groupCenter entity');
@ -181,10 +182,9 @@ class UserController extends CRUDController
$em->flush(); $em->flush();
$this->addFlash('success', $this->get('translator') $this->addFlash('success', $this->get('translator')
->trans('The permissions where removed.')); ->trans('The permissions where removed.'));
return $this->redirect($this->generateUrl('chill_crud_admin_user_edit', array('id' => $uid))); return $this->redirect($this->generateUrl('chill_crud_admin_user_edit', array('id' => $uid)));
} }
/** /**
@ -206,24 +206,26 @@ class UserController extends CRUDController
if ($form->isValid()) { if ($form->isValid()) {
$groupCenter = $this->getPersistedGroupCenter( $groupCenter = $this->getPersistedGroupCenter(
$form[self::FORM_GROUP_CENTER_COMPOSED]->getData()); $form[self::FORM_GROUP_CENTER_COMPOSED]->getData()
);
$user->addGroupCenter($groupCenter); $user->addGroupCenter($groupCenter);
if ($this->validator->validate($user)->count() === 0) { if ($this->validator->validate($user)->count() === 0) {
$em->flush(); $em->flush();
$this->addFlash('success', $this->get('translator')->trans('The ' $this->addFlash('success', $this->get('translator')->trans('The '
. 'permissions have been successfully added to the user')); . 'permissions have been successfully added to the user'));
$returnPathParams = $request->query->has('returnPath') ? $returnPathParams = $request->query->has('returnPath') ?
['returnPath' => $request->query->get('returnPath')] : []; ['returnPath' => $request->query->get('returnPath')] : [];
return $this->redirect($this->generateUrl('chill_crud_admin_user_edit',
\array_merge(['id' => $uid], $returnPathParams)));
return $this->redirect($this->generateUrl(
'chill_crud_admin_user_edit',
\array_merge(['id' => $uid], $returnPathParams)
));
} }
foreach($this->validator->validate($user) as $error) { foreach ($this->validator->validate($user) as $error) {
$this->addFlash('error', $error->getMessage()); $this->addFlash('error', $error->getMessage());
} }
} }
@ -233,7 +235,7 @@ class UserController extends CRUDController
'edit_form' => $this->createEditForm($user)->createView(), 'edit_form' => $this->createEditForm($user)->createView(),
'add_groupcenter_form' => $this->createAddLinkGroupCenterForm($user, $request)->createView(), 'add_groupcenter_form' => $this->createAddLinkGroupCenterForm($user, $request)->createView(),
'delete_groupcenter_form' => array_map( 'delete_groupcenter_form' => array_map(
static fn(Form $form) => $form->createView(), static fn (Form $form) => $form->createView(),
iterator_to_array($this->getDeleteLinkGroupCenterByUser($user, $request), true) iterator_to_array($this->getDeleteLinkGroupCenterByUser($user, $request), true)
) )
]); ]);
@ -244,10 +246,10 @@ class UserController extends CRUDController
$em = $this->getDoctrine()->getManager(); $em = $this->getDoctrine()->getManager();
$groupCenterManaged = $em->getRepository('ChillMainBundle:GroupCenter') $groupCenterManaged = $em->getRepository('ChillMainBundle:GroupCenter')
->findOneBy(array( ->findOneBy(array(
'center' => $groupCenter->getCenter(), 'center' => $groupCenter->getCenter(),
'permissionsGroup' => $groupCenter->getPermissionsGroup() 'permissionsGroup' => $groupCenter->getPermissionsGroup()
)); ));
if (!$groupCenterManaged) { if (!$groupCenterManaged) {
$em->persist($groupCenter); $em->persist($groupCenter);
@ -267,8 +269,10 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl('admin_user_delete_groupcenter', ->setAction($this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()]))) 'admin_user_delete_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()])
))
->setMethod('DELETE') ->setMethod('DELETE')
->add('submit', SubmitType::class, array('label' => 'Delete')) ->add('submit', SubmitType::class, array('label' => 'Delete'))
->getForm(); ->getForm();
@ -282,8 +286,10 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl('admin_user_add_groupcenter', ->setAction($this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId()]))) 'admin_user_add_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId()])
))
->setMethod('POST') ->setMethod('POST')
->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class) ->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class)
->add('submit', SubmitType::class, array('label' => 'Add a new groupCenter')) ->add('submit', SubmitType::class, array('label' => 'Add a new groupCenter'))
@ -296,4 +302,36 @@ class UserController extends CRUDController
yield $groupCenter->getId() => $this->createDeleteLinkGroupCenterForm($user, $groupCenter, $request); yield $groupCenter->getId() => $this->createDeleteLinkGroupCenterForm($user, $groupCenter, $request);
} }
} }
/**
* Displays a form to edit the user current location.
*
* @Route("/{_locale}/main/user/current-location/edit", name="chill_main_user_currentlocation_edit")
*/
public function editCurrentLocationAction(Request $request)
{
$user = $this->getUser();
$form = $this->createForm(UserCurrentLocationType::class, $user)
->add('submit', SubmitType::class, ['label' => 'Save'])
->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$currentLocation = $form->get('currentLocation')->getData();
$user->setCurrentLocation($currentLocation);
$this->getDoctrine()->getManager()->flush();
$this->addFlash('success', $this->get('translator')->trans('Current location successfully updated'));
return $this->redirect(
$request->query->has('returnPath') ? $request->query->get('returnPath') :
$this->generateUrl('chill_main_homepage')
);
}
return $this->render('@ChillMain/User/edit_current_location.html.twig', [
'entity' => $user,
'edit_form' => $form->createView()
]);
}
} }

View File

@ -1,19 +1,19 @@
<?php <?php
/* /*
* *
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop> * Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the * published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version. * License, or (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
@ -23,12 +23,11 @@ namespace Chill\MainBundle\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Symfony\Component\Serializer\Annotation as Serializer;
/** /**
* @ORM\Entity * @ORM\Entity
* @ORM\Table(name="centers") * @ORM\Table(name="centers")
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/ */
class Center implements HasCenterInterface class Center implements HasCenterInterface
{ {
@ -46,9 +45,10 @@ class Center implements HasCenterInterface
* @var string * @var string
* *
* @ORM\Column(type="string", length=255) * @ORM\Column(type="string", length=255)
* @Serializer\Groups({"docgen:read"})
*/ */
private $name; private string $name = '';
/** /**
* @var Collection * @var Collection
* *
@ -58,8 +58,8 @@ class Center implements HasCenterInterface
* ) * )
*/ */
private $groupCenters; private $groupCenters;
/** /**
* Center constructor. * Center constructor.
*/ */
@ -67,7 +67,7 @@ class Center implements HasCenterInterface
{ {
$this->groupCenters = new \Doctrine\Common\Collections\ArrayCollection(); $this->groupCenters = new \Doctrine\Common\Collections\ArrayCollection();
} }
/** /**
* @return string * @return string
*/ */
@ -75,7 +75,7 @@ class Center implements HasCenterInterface
{ {
return $this->name; return $this->name;
} }
/** /**
* @param $name * @param $name
* @return $this * @return $this
@ -85,7 +85,7 @@ class Center implements HasCenterInterface
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
/** /**
* @return int * @return int
*/ */
@ -93,7 +93,7 @@ class Center implements HasCenterInterface
{ {
return $this->id; return $this->id;
} }
/** /**
* @return ArrayCollection|Collection * @return ArrayCollection|Collection
*/ */
@ -101,7 +101,7 @@ class Center implements HasCenterInterface
{ {
return $this->groupCenters; return $this->groupCenters;
} }
/** /**
* @param GroupCenter $groupCenter * @param GroupCenter $groupCenter
* @return $this * @return $this
@ -111,7 +111,7 @@ class Center implements HasCenterInterface
$this->groupCenters->add($groupCenter); $this->groupCenters->add($groupCenter);
return $this; return $this;
} }
/** /**
* @return string * @return string
*/ */
@ -119,7 +119,7 @@ class Center implements HasCenterInterface
{ {
return $this->getName(); return $this->getName();
} }
/** /**
* @return $this|Center * @return $this|Center
*/ */

View File

@ -6,9 +6,10 @@ use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Entity\Location;
use Symfony\Component\Security\Core\User\AdvancedUserInterface; use Symfony\Component\Security\Core\User\AdvancedUserInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation as Serializer;
/** /**
* User * User
@ -16,7 +17,7 @@ use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
* @ORM\Entity * @ORM\Entity
* @ORM\Table(name="users") * @ORM\Table(name="users")
* @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="acl_cache_region") * @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="acl_cache_region")
* @DiscriminatorMap(typeProperty="type", mapping={ * @Serializer\DiscriminatorMap(typeProperty="type", mapping={
* "user"=User::class * "user"=User::class
* }) * })
*/ */
@ -51,6 +52,7 @@ class User implements AdvancedUserInterface {
/** /**
* @ORM\Column(type="string", length=200) * @ORM\Column(type="string", length=200)
* @Serializer\Groups({"docgen:read"})
*/ */
private string $label = ''; private string $label = '';
@ -58,8 +60,9 @@ class User implements AdvancedUserInterface {
* @var string * @var string
* *
* @ORM\Column(type="string", length=150, nullable=true) * @ORM\Column(type="string", length=150, nullable=true)
* @Serializer\Groups({"docgen:read"})
*/ */
private $email; private ?string $email = null;
/** /**
* @var string * @var string
@ -123,6 +126,7 @@ class User implements AdvancedUserInterface {
/** /**
* @var Center|null * @var Center|null
* @ORM\ManyToOne(targetEntity=Center::class) * @ORM\ManyToOne(targetEntity=Center::class)
* @Serializer\Groups({"docgen:read"})
*/ */
private ?Center $mainCenter = null; private ?Center $mainCenter = null;
@ -138,6 +142,12 @@ class User implements AdvancedUserInterface {
*/ */
private ?UserJob $userJob = null; private ?UserJob $userJob = null;
/**
* @var Location|null
* @ORM\ManyToOne(targetEntity=Location::class)
*/
private ?Location $currentLocation = null;
/** /**
* User constructor. * User constructor.
*/ */
@ -485,4 +495,22 @@ class User implements AdvancedUserInterface {
$this->userJob = $userJob; $this->userJob = $userJob;
return $this; return $this;
} }
/**
* @return Location|null
*/
public function getCurrentLocation(): ?Location
{
return $this->currentLocation;
}
/**
* @param Location|null $location
* @return User
*/
public function setCurrentLocation(?Location $currentLocation): User
{
$this->currentLocation = $currentLocation;
return $this;
}
} }

View File

@ -15,15 +15,15 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class LocationFormType extends AbstractType final class LocationFormType extends AbstractType
{ {
// private TranslatableStringHelper $translatableStringHelper; private TranslatableStringHelper $translatableStringHelper;
// /** /**
// * @param TranslatableStringHelper $translatableStringHelper * @param TranslatableStringHelper $translatableStringHelper
// */ */
// public function __construct(TranslatableStringHelper $translatableStringHelper) public function __construct(TranslatableStringHelper $translatableStringHelper)
// { {
// $this->translatableStringHelper = $translatableStringHelper; $this->translatableStringHelper = $translatableStringHelper;
// } }
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
@ -38,8 +38,7 @@ final class LocationFormType extends AbstractType
]; ];
}, },
'choice_label' => function (EntityLocationType $entity) { 'choice_label' => function (EntityLocationType $entity) {
//return $this->translatableStringHelper->localize($entity->getTitle()); //TODO not working. Cannot pass smthg in the constructor return $this->translatableStringHelper->localize($entity->getTitle());
return $entity->getTitle()['fr'];
}, },
]) ])
->add('name', TextType::class) ->add('name', TextType::class)

View File

@ -0,0 +1,42 @@
<?php
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
class UserCurrentLocationType extends AbstractType
{
private LocationRepository $locationRepository;
private TranslatableStringHelper $translatableStringHelper;
/**
* @param TranslatableStringHelper $translatableStringHelper
*/
public function __construct(TranslatableStringHelper $translatableStringHelper, LocationRepository $locationRepository)
{
$this->translatableStringHelper = $translatableStringHelper;
$this->locationRepository = $locationRepository;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('currentLocation', EntityType::class, [
'class' => Location::class,
'choices' => $this->locationRepository->findByPublicLocations(),
'choice_label' => function (Location $entity) {
return $entity->getName() ?
$entity->getName().' ('.$this->translatableStringHelper->localize($entity->getLocationType()->getTitle()).')' :
$this->translatableStringHelper->localize($entity->getLocationType()->getTitle());
},
'placeholder' => 'Pick a location',
'required' => false,
]);
}
}

View File

@ -18,4 +18,14 @@ class LocationRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Location::class); parent::__construct($registry, Location::class);
} }
/**
* fetch locations which are selectable for users
*
* @return Location[]
*/
public function findByPublicLocations()
{
return $this->findBy(['active' => true, 'availableForUsers' => true]);
}
} }

View File

@ -402,3 +402,11 @@ input.belgian_national_number {
&.daily_counter {} &.daily_counter {}
&.control_digit {} &.control_digit {}
} }
// replace abbr
span.item-key {
font-variant: all-small-caps;
font-size: 90%;
background-color: #0000000a;
//text-decoration: dotted underline;
}

View File

@ -8,3 +8,43 @@ require('./bootstrap.scss');
import Dropdown from 'bootstrap/js/src/dropdown'; import Dropdown from 'bootstrap/js/src/dropdown';
import Modal from 'bootstrap/js/dist/modal'; import Modal from 'bootstrap/js/dist/modal';
import Collapse from 'bootstrap/js/src/collapse'; import Collapse from 'bootstrap/js/src/collapse';
import Carousel from 'bootstrap/js/src/carousel';
//
// ACHeaderSlider is a small slider used in banner of AccompanyingCourse Section
// Initialize options, and show/hide controls in first/last slides
//
let ACHeaderSlider = document.querySelector('#ACHeaderSlider');
if (ACHeaderSlider) {
let controlPrev = ACHeaderSlider.querySelector('button[data-bs-slide="prev"]'),
controlNext = ACHeaderSlider.querySelector('button[data-bs-slide="next"]'),
length = ACHeaderSlider.querySelectorAll('.carousel-item').length,
last = length-1,
carousel = new Carousel(ACHeaderSlider, {
interval: false,
wrap: false,
ride: false,
keyboard: false,
touch: true
})
;
document.addEventListener('DOMContentLoaded', (e) => {
controlNext.classList.remove('visually-hidden');
});
ACHeaderSlider.addEventListener('slid.bs.carousel', (e) => {
//console.log('from slide', e.direction, e.relatedTarget, e.from, e.to );
switch (e.to) {
case 0:
controlPrev.classList.add('visually-hidden');
controlNext.classList.remove('visually-hidden');
break;
case last:
controlPrev.classList.remove('visually-hidden');
controlNext.classList.add('visually-hidden');
break;
default:
controlPrev.classList.remove('visually-hidden');
controlNext.classList.remove('visually-hidden');
}
})
}

View File

@ -0,0 +1,21 @@
{% extends 'ChillMainBundle::layout.html.twig' %}
{% block title %}{{ 'Edit my current location'|trans }}{% endblock %}
{% block content -%}
<div class="col-md-10 col-xxl">
<h1>{{ 'Edit my current location'|trans }}</h1>
{{ form_start(edit_form) }}
{{ form_row(edit_form.currentLocation) }}
<ul class="record_actions sticky-form-buttons">
<li>
{{ form_widget(edit_form.submit, { 'attr': { 'class': 'btn btn-edit' } } ) }}
</li>
</ul>
{{ form_end(edit_form) }}
</div>
{% endblock %}

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

@ -20,46 +20,57 @@ namespace Chill\MainBundle\Routing\MenuBuilder;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Security;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class UserMenuBuilder implements LocalMenuBuilderInterface class UserMenuBuilder implements LocalMenuBuilderInterface
{ {
/** private Security $security;
*
* @var TokenStorageInterface public function __construct(Security $security)
*/
protected $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{ {
$this->tokenStorage = $tokenStorage; $this->security = $security;
} }
public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters) public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters)
{ {
if ($this->tokenStorage->getToken()->getUser() instanceof User) { $user = $this->security->getUser();
if ($user instanceof User) {
if (null !== $user->getCurrentLocation()) {
$locationTextMenu = $user->getCurrentLocation()->getName();
} else {
$locationTextMenu = 'Set a location';
}
$menu
->addChild(
$locationTextMenu,
['route' => 'chill_main_user_currentlocation_edit']
)
->setExtras([
'order' => -9999999,
'icon' => 'map-marker'
]);
$menu $menu
->addChild( ->addChild(
'Change password', 'Change password',
[ 'route' => 'change_my_password'] ['route' => 'change_my_password']
) )
->setExtras([ ->setExtras([
'order' => 99999999998 'order' => 99999999998
]); ]);
} }
$menu $menu
->addChild( ->addChild(
'Logout', 'Logout',
[ [
'route' => 'logout' 'route' => 'logout'
]) ]
)
->setExtras([ ->setExtras([
'order'=> 99999999999, 'order' => 99999999999,
'icon' => 'power-off' 'icon' => 'power-off'
]); ]);
} }

View File

@ -26,10 +26,10 @@ use Chill\MainBundle\Search\ParsingException;
/** /**
* This class implements abstract search with most common responses. * This class implements abstract search with most common responses.
* *
* you should use this abstract class instead of SearchInterface : if the signature of * you should use this abstract class instead of SearchInterface : if the signature of
* search interface change, the generic method will be implemented here. * search interface change, the generic method will be implemented here.
* *
* @author Julien Fastré <julien.fastre@champs-libres.coop> * @author Julien Fastré <julien.fastre@champs-libres.coop>
* *
*/ */
@ -37,7 +37,7 @@ abstract class AbstractSearch implements SearchInterface
{ {
/** /**
* parse string expected to be a date and transform to a DateTime object * parse string expected to be a date and transform to a DateTime object
* *
* @param type $string * @param type $string
* @return \DateTime * @return \DateTime
* @throws ParsingException if the date is not parseable * @throws ParsingException if the date is not parseable
@ -51,14 +51,14 @@ abstract class AbstractSearch implements SearchInterface
. 'not parsable', 0, $ex); . 'not parsable', 0, $ex);
throw $exception; throw $exception;
} }
} }
/** /**
* recompose a pattern, retaining only supported terms * recompose a pattern, retaining only supported terms
* *
* the outputted string should be used to show users their search * the outputted string should be used to show users their search
* *
* @param array $terms * @param array $terms
* @param array $supportedTerms * @param array $supportedTerms
* @param string $domain if your domain is NULL, you should set NULL. You should set used domain instead * @param string $domain if your domain is NULL, you should set NULL. You should set used domain instead
@ -67,35 +67,35 @@ abstract class AbstractSearch implements SearchInterface
protected function recomposePattern(array $terms, array $supportedTerms, $domain = NULL) protected function recomposePattern(array $terms, array $supportedTerms, $domain = NULL)
{ {
$recomposed = ''; $recomposed = '';
if ($domain !== NULL) if ($domain !== NULL)
{ {
$recomposed .= '@'.$domain.' '; $recomposed .= '@'.$domain.' ';
} }
foreach ($supportedTerms as $term) { foreach ($supportedTerms as $term) {
if (array_key_exists($term, $terms) && $term !== '_default') { if (array_key_exists($term, $terms) && $term !== '_default') {
$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 .= '"';
} }
} }
} }
if ($terms['_default'] !== '') { if ($terms['_default'] !== '') {
$recomposed .= ' '.$terms['_default']; $recomposed .= ' '.$terms['_default'];
} }
//strip first character if empty //strip first character if empty
if (mb_strcut($recomposed, 0, 1) === ' '){ if (mb_strcut($recomposed, 0, 1) === ' '){
$recomposed = mb_strcut($recomposed, 1); $recomposed = mb_strcut($recomposed, 1);
} }
return $recomposed; return $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

@ -10,10 +10,10 @@ use Chill\MainBundle\Search\HasAdvancedSearchFormInterface;
* installed into the app. * installed into the app.
* the service is callable from the container with * the service is callable from the container with
* $container->get('chill_main.search_provider') * $container->get('chill_main.search_provider')
* *
* the syntax for search string is : * the syntax for search string is :
* - domain, which begin with `@`. Example: `@person`. Restrict the search to some * - domain, which begin with `@`. Example: `@person`. Restrict the search to some
* entities. It may exists multiple search provider for the same domain (example: * entities. It may exists multiple search provider for the same domain (example:
* a search provider for people which performs regular search, and suggestion search * a search provider for people which performs regular search, and suggestion search
* with phonetical algorithms * with phonetical algorithms
* - terms, which are the terms of the search. There are terms with argument (example : * - terms, which are the terms of the search. There are terms with argument (example :
@ -25,17 +25,17 @@ class SearchProvider
{ {
/** /**
* *
* @var SearchInterface[] * @var SearchInterface[]
*/ */
private $searchServices = array(); private $searchServices = array();
/** /**
* *
* @var HasAdvancedSearchForm[] * @var HasAdvancedSearchForm[]
*/ */
private $hasAdvancedFormSearchServices = array(); private $hasAdvancedFormSearchServices = array();
/* /*
* return search services in an array, ordered by * return search services in an array, ordered by
* the order key (defined in service definition) * the order key (defined in service definition)
@ -59,7 +59,7 @@ class SearchProvider
return $this->searchServices; return $this->searchServices;
} }
public function getHasAdvancedFormSearchServices() public function getHasAdvancedFormSearchServices()
{ {
//sort the array //sort the array
@ -75,7 +75,7 @@ class SearchProvider
/** /**
* parse the search string to extract domain and terms * parse the search string to extract domain and terms
* *
* @param string $pattern * @param string $pattern
* @return string[] an array where the keys are _domain, _default (residual terms) or term * @return string[] an array where the keys are _domain, _default (residual terms) or term
*/ */
@ -95,9 +95,9 @@ class SearchProvider
/** /**
* Extract the domain of the subject * Extract the domain of the subject
* *
* The domain begins with `@`. Example: `@person`, `@report`, .... * The domain begins with `@`. Example: `@person`, `@report`, ....
* *
* @param type $subject * @param type $subject
* @return string * @return string
* @throws ParsingException * @throws ParsingException
@ -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;
@ -139,14 +140,14 @@ class SearchProvider
/** /**
* store string which must be extracted to find default arguments * store string which must be extracted to find default arguments
* *
* @var string[] * @var string[]
*/ */
private $mustBeExtracted = array(); private $mustBeExtracted = array();
/** /**
* extract default (residual) arguments * extract default (residual) arguments
* *
* @param string $subject * @param string $subject
* @return string * @return string
*/ */
@ -158,7 +159,7 @@ class SearchProvider
/** /**
* search through services which supports domain and give * search through services which supports domain and give
* results as an array of resultsfrom different SearchInterface * results as an array of resultsfrom different SearchInterface
* *
* @param string $pattern * @param string $pattern
* @param number $start * @param number $start
* @param number $limit * @param number $limit
@ -167,25 +168,25 @@ class SearchProvider
* @return array of results from different SearchInterface * @return array of results from different SearchInterface
* @throws UnknowSearchDomainException if the domain is unknow * @throws UnknowSearchDomainException if the domain is unknow
*/ */
public function getSearchResults($pattern, $start = 0, $limit = 50, public function getSearchResults($pattern, $start = 0, $limit = 50,
array $options = array(), $format = 'html') array $options = array(), $format = 'html')
{ {
$terms = $this->parse($pattern); $terms = $this->parse($pattern);
$results = array(); $results = array();
//sort searchServices by order //sort searchServices by order
$sortedSearchServices = array(); $sortedSearchServices = array();
foreach($this->searchServices as $service) { foreach($this->searchServices as $service) {
$sortedSearchServices[$service->getOrder()] = $service; $sortedSearchServices[$service->getOrder()] = $service;
} }
if ($terms['_domain'] !== NULL) { if ($terms['_domain'] !== NULL) {
foreach ($sortedSearchServices as $service) { foreach ($sortedSearchServices as $service) {
if ($service->supports($terms['_domain'], $format)) { if ($service->supports($terms['_domain'], $format)) {
$results[] = $service->renderResult($terms, $start, $limit, $options); $results[] = $service->renderResult($terms, $start, $limit, $options);
} }
} }
if (count($results) === 0) { if (count($results) === 0) {
throw new UnknowSearchDomainException($terms['_domain']); throw new UnknowSearchDomainException($terms['_domain']);
} }
@ -196,24 +197,24 @@ class SearchProvider
} }
} }
} }
//sort array //sort array
ksort($results); ksort($results);
return $results; return $results;
} }
public function getResultByName($pattern, $name, $start = 0, $limit = 50, public function getResultByName($pattern, $name, $start = 0, $limit = 50,
array $options = array(), $format = 'html') array $options = array(), $format = 'html')
{ {
$terms = $this->parse($pattern); $terms = $this->parse($pattern);
$search = $this->getByName($name); $search = $this->getByName($name);
if ($terms['_domain'] !== NULL && !$search->supports($terms['_domain'], $format)) if ($terms['_domain'] !== NULL && !$search->supports($terms['_domain'], $format))
{ {
throw new ParsingException("The domain is not supported for the search name"); throw new ParsingException("The domain is not supported for the search name");
} }
return $search->renderResult($terms, $start, $limit, $options, $format); return $search->renderResult($terms, $start, $limit, $options, $format);
} }
@ -232,16 +233,16 @@ class SearchProvider
throw new UnknowSearchNameException($name); throw new UnknowSearchNameException($name);
} }
} }
/** /**
* return searchservice with an advanced form, defined in service * return searchservice with an advanced form, defined in service
* definition. * definition.
* *
* @param string $name * @param string $name
* @return HasAdvancedSearchForm * @return HasAdvancedSearchForm
* @throws UnknowSearchNameException * @throws UnknowSearchNameException
*/ */
public function getHasAdvancedFormByName($name) public function getHasAdvancedFormByName($name)
{ {
if (\array_key_exists($name, $this->hasAdvancedFormSearchServices)) { if (\array_key_exists($name, $this->hasAdvancedFormSearchServices)) {
return $this->hasAdvancedFormSearchServices[$name]; return $this->hasAdvancedFormSearchServices[$name];
@ -253,7 +254,7 @@ class SearchProvider
public function addSearchService(SearchInterface $service, $name) public function addSearchService(SearchInterface $service, $name)
{ {
$this->searchServices[$name] = $service; $this->searchServices[$name] = $service;
if ($service instanceof HasAdvancedSearchFormInterface) { if ($service instanceof HasAdvancedSearchFormInterface) {
$this->hasAdvancedFormSearchServices[$name] = $service; $this->hasAdvancedFormSearchServices[$name] = $service;
} }
@ -477,7 +478,7 @@ class SearchProvider
$string = strtr($string, $chars); $string = strtr($string, $chars);
} /* remove from wordpress: we use only utf 8 } /* remove from wordpress: we use only utf 8
* else { * else {
// Assume ISO-8859-1 if not UTF-8 // Assume ISO-8859-1 if not UTF-8
$chars['in'] = chr(128) . chr(131) . chr(138) . chr(142) . chr(154) . chr(158) $chars['in'] = chr(128) . chr(131) . chr(138) . chr(142) . chr(154) . chr(158)
. chr(159) . chr(162) . chr(165) . chr(181) . chr(192) . chr(193) . chr(194) . chr(159) . chr(162) . chr(165) . chr(181) . chr(192) . chr(193) . chr(194)

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

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Security\Resolver; namespace Chill\MainBundle\Security\Resolver;
use Chill\MainBundle\Entity\Center;
final class CenterResolverManager implements CenterResolverManagerInterface final class CenterResolverManager implements CenterResolverManagerInterface
{ {
/** /**
@ -20,7 +22,17 @@ final class CenterResolverManager implements CenterResolverManagerInterface
{ {
foreach($this->resolvers as $resolver) { foreach($this->resolvers as $resolver) {
if ($resolver->supports($entity, $options)) { if ($resolver->supports($entity, $options)) {
return (array) $resolver->resolveCenter($entity, $options); $resolved = $resolver->resolveCenter($entity, $options);
if (null === $resolved) {
return [];
} elseif ($resolved instanceof Center) {
return [$resolved];
} elseif (\is_array($resolved)) {
return $resolved;
}
throw new \UnexpectedValueException(sprintf("the return type of a %s should be an instance of %s, an array or null. Resolver is %s",
CenterResolverInterface::class, Center::class, get_class($resolver)));
} }
} }

View File

@ -6,20 +6,21 @@ use Twig\TwigFilter;
final class ResolverTwigExtension extends \Twig\Extension\AbstractExtension final class ResolverTwigExtension extends \Twig\Extension\AbstractExtension
{ {
private CenterResolverDispatcher $centerResolverDispatcher; private CenterResolverManagerInterface $centerResolverDispatcher;
private ScopeResolverDispatcher $scopeResolverDispatcher;
/** public function __construct(CenterResolverManagerInterface $centerResolverDispatcher, ScopeResolverDispatcher $scopeResolverDispatcher)
* @param CenterResolverDispatcher $centerResolverDispatcher
*/
public function __construct(CenterResolverDispatcher $centerResolverDispatcher)
{ {
$this->centerResolverDispatcher = $centerResolverDispatcher; $this->centerResolverDispatcher = $centerResolverDispatcher;
$this->scopeResolverDispatcher = $scopeResolverDispatcher;
} }
public function getFilters() public function getFilters()
{ {
return [ return [
new TwigFilter('chill_resolve_center', [$this, 'resolveCenter']) new TwigFilter('chill_resolve_center', [$this, 'resolveCenter']),
new TwigFilter('chill_resolve_scope', [$this, 'resolveScope']),
new TwigFilter('chill_is_scope_concerned', [$this, 'isScopeConcerned']),
]; ];
} }
@ -30,7 +31,26 @@ final class ResolverTwigExtension extends \Twig\Extension\AbstractExtension
*/ */
public function resolveCenter($entity, ?array $options = []) public function resolveCenter($entity, ?array $options = [])
{ {
return $this->centerResolverDispatcher->resolveCenter($entity, $options); return $this->centerResolverDispatcher->resolveCenters($entity, $options);
} }
/**
* @param $entity
* @param array|null $options
* @return bool
*/
public function isScopeConcerned($entity, ?array $options = [])
{
return $this->scopeResolverDispatcher->isConcerned($entity, $options);
}
/**
* @param $entity
* @param array|null $options
* @return array|\Chill\MainBundle\Entity\Scope|\Chill\MainBundle\Entity\Scope[]
*/
public function resolveScope($entity, ?array $options = [])
{
return $this->scopeResolverDispatcher->resolveScope();
}
} }

View File

@ -1,18 +1,18 @@
<?php <?php
/* /*
* *
* Copyright (C) 2014-2021, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop> * Copyright (C) 2014-2021, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the * published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version. * License, or (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
@ -27,7 +27,7 @@ use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/** /**
* *
* *
*/ */
class CenterNormalizer implements NormalizerInterface, DenormalizerInterface class CenterNormalizer implements NormalizerInterface, DenormalizerInterface
@ -52,7 +52,7 @@ class CenterNormalizer implements NormalizerInterface, DenormalizerInterface
public function supportsNormalization($data, string $format = null): bool public function supportsNormalization($data, string $format = null): bool
{ {
return $data instanceof Center; return $data instanceof Center && $format === 'json';
} }
public function denormalize($data, string $type, string $format = null, array $context = []) public function denormalize($data, string $type, string $format = null, array $context = [])

View File

@ -19,23 +19,78 @@
namespace Chill\MainBundle\Serializer\Normalizer; namespace Chill\MainBundle\Serializer\Normalizer;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class DateNormalizer implements NormalizerInterface, DenormalizerInterface class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInterface
{ {
private RequestStack $requestStack;
private ParameterBagInterface $parameterBag;
public function __construct(RequestStack $requestStack, ParameterBagInterface $parameterBag)
{
$this->requestStack = $requestStack;
$this->parameterBag = $parameterBag;
}
public function normalize($date, string $format = null, array $context = array()) public function normalize($date, string $format = null, array $context = array())
{ {
/** @var \DateTimeInterface $date */ /** @var \DateTimeInterface $date */
return [ switch($format) {
'datetime' => $date->format(\DateTimeInterface::ISO8601) case 'json':
]; return [
'datetime' => $date->format(\DateTimeInterface::ISO8601)
];
case 'docgen':
if (null === $date) {
return [
'long' => '', 'short' => ''
];
}
$hasTime = $date->format('His') !== "000000";
$request = $this->requestStack->getCurrentRequest();
$locale = null !== $request ? $request->getLocale() : $this->parameterBag->get('kernel.default_locale');
$formatterLong = \IntlDateFormatter::create(
$locale,
\IntlDateFormatter::LONG,
$hasTime ? \IntlDateFormatter::SHORT: \IntlDateFormatter::NONE
);
$formatterShort = \IntlDateFormatter::create(
$locale,
\IntlDateFormatter::SHORT,
$hasTime ? \IntlDateFormatter::SHORT: \IntlDateFormatter::NONE
);
return [
'short' => $formatterShort->format($date),
'long' => $formatterLong->format($date)
];
}
} }
public function supportsNormalization($data, string $format = null): bool public function supportsNormalization($data, string $format = null, array $context = []): bool
{ {
return $data instanceof \DateTimeInterface; if ($format === 'json') {
return $data instanceof \DateTimeInterface;
} elseif ($format === 'docgen') {
return $data instanceof \DateTimeInterface || (
$data === null
&& \array_key_exists('docgen:expects', $context)
&& (
$context['docgen:expects'] === \DateTimeInterface::class
|| $context['docgen:expects'] === \DateTime::class
|| $context['docgen:expects'] === \DateTimeImmutable::class
)
);
}
return false;
} }
public function denormalize($data, string $type, string $format = null, array $context = []) public function denormalize($data, string $type, string $format = null, array $context = [])

View File

@ -52,6 +52,6 @@ class UserNormalizer implements NormalizerInterface, NormalizerAwareInterface
public function supportsNormalization($data, string $format = null): bool public function supportsNormalization($data, string $format = null): bool
{ {
return $data instanceof User; return $format === 'json' && $data instanceof User;
} }
} }

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

@ -26,17 +26,17 @@ use PHPUnit\Framework\TestCase;
class SearchProviderTest extends TestCase class SearchProviderTest extends TestCase
{ {
/** /**
* *
* @var SearchProvider * @var SearchProvider
*/ */
private $search; private $search;
public function setUp() public function setUp()
{ {
$this->search = new SearchProvider(); $this->search = new SearchProvider();
//add a default service //add a default service
$this->addSearchService( $this->addSearchService(
$this->createDefaultSearchService('I am default', 10), 'default' $this->createDefaultSearchService('I am default', 10), 'default'
@ -46,7 +46,7 @@ class SearchProviderTest extends TestCase
$this->createNonDefaultDomainSearchService('I am domain bar', 20, FALSE), 'bar' $this->createNonDefaultDomainSearchService('I am domain bar', 20, FALSE), 'bar'
); );
} }
/** /**
* @expectedException \Chill\MainBundle\Search\UnknowSearchNameException * @expectedException \Chill\MainBundle\Search\UnknowSearchNameException
*/ */
@ -54,11 +54,11 @@ class SearchProviderTest extends TestCase
{ {
$this->search->getByName("invalid name"); $this->search->getByName("invalid name");
} }
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',
'birthdate' => '2014-01-02', 'birthdate' => '2014-01-02',
@ -66,40 +66,40 @@ class SearchProviderTest extends TestCase
'name' => 'my name' 'name' => 'my name'
), $terms); ), $terms);
} }
public function testWithoutDomain() public function testWithoutDomain()
{ {
$terms = $this->p('foo:bar residual'); $terms = $this->p('foo:bar residual');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => null, '_domain' => null,
'foo' => 'bar', 'foo' => 'bar',
'_default' => 'residual' '_default' => 'residual'
), $terms); ), $terms);
} }
public function testWithoutDefault() public function testWithoutDefault()
{ {
$terms = $this->p('@person foo:bar'); $terms = $this->p('@person foo:bar');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => 'person', '_domain' => 'person',
'foo' => 'bar', 'foo' => 'bar',
'_default' => '' '_default' => ''
), $terms); ), $terms);
} }
public function testCapitalLetters() public function testCapitalLetters()
{ {
$terms = $this->p('Foo:Bar LOL marCi @PERSON'); $terms = $this->p('Foo:Bar LOL marCi @PERSON');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => 'person', '_domain' => 'person',
'_default' => 'lol marci', '_default' => 'lol marci',
'foo' => 'bar' 'foo' => 'bar'
), $terms); ), $terms);
} }
/** /**
* @expectedException Chill\MainBundle\Search\ParsingException * @expectedException Chill\MainBundle\Search\ParsingException
*/ */
@ -107,12 +107,11 @@ class SearchProviderTest extends TestCase
{ {
$term = $this->p("@person @report"); $term = $this->p("@person @report");
} }
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',
'name' => 'my beautiful name', 'name' => 'my beautiful name',
@ -120,65 +119,65 @@ class SearchProviderTest extends TestCase
'surname' => 'i love techno' 'surname' => 'i love techno'
), $terms); ), $terms);
} }
public function testAccentued() public function testAccentued()
{ {
//$this->markTestSkipped('accentued characters must be implemented'); //$this->markTestSkipped('accentued characters must be implemented');
$terms = $this->p('manço bélier aztèque à saloù ê'); $terms = $this->p('manço bélier aztèque à saloù ê');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => NULL, '_domain' => NULL,
'_default' => 'manco belier azteque a salou e' '_default' => 'manco belier azteque a salou e'
), $terms); ), $terms);
} }
public function testAccentuedCapitals() public function testAccentuedCapitals()
{ {
//$this->markTestSkipped('accentued characters must be implemented'); //$this->markTestSkipped('accentued characters must be implemented');
$terms = $this->p('MANÉÀ oÛ lÎ À'); $terms = $this->p('MANÉÀ oÛ lÎ À');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => null, '_domain' => null,
'_default' => 'manea ou li a' '_default' => 'manea ou li a'
), $terms); ), $terms);
} }
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,
'foo' => 'bar', 'foo' => 'bar',
'_default' => '' '_default' => ''
), $terms); ), $terms);
} }
public function testTrimInDefault() public function testTrimInDefault()
{ {
$terms = $this->p(' foo bar '); $terms = $this->p(' foo bar ');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => null, '_domain' => null,
'_default' => 'foo bar' '_default' => 'foo bar'
), $terms); ), $terms);
} }
public function testArgumentNameWithTrait() public function testArgumentNameWithTrait()
{ {
$terms = $this->p('date-from:2016-05-04'); $terms = $this->p('date-from:2016-05-04');
$this->assertEquals(array( $this->assertEquals(array(
'_domain' => null, '_domain' => null,
'date-from' => '2016-05-04', 'date-from' => '2016-05-04',
'_default' => '' '_default' => ''
), $terms); ), $terms);
} }
/** /**
* Test the behaviour when no domain is provided in the search pattern : * Test the behaviour when no domain is provided in the search pattern :
* the default search should be enabled * the default search should be enabled
*/ */
public function testSearchResultDefault() public function testSearchResultDefault()
@ -186,12 +185,12 @@ class SearchProviderTest extends TestCase
$response = $this->search->getSearchResults('default search'); $response = $this->search->getSearchResults('default search');
//$this->markTestSkipped(); //$this->markTestSkipped();
$this->assertEquals(array( $this->assertEquals(array(
"I am default" "I am default"
), $response); ), $response);
} }
/** /**
* @expectedException \Chill\MainBundle\Search\UnknowSearchDomainException * @expectedException \Chill\MainBundle\Search\UnknowSearchDomainException
*/ */
@ -200,49 +199,49 @@ class SearchProviderTest extends TestCase
$response = $this->search->getSearchResults('@unknow domain'); $response = $this->search->getSearchResults('@unknow domain');
//$this->markTestSkipped(); //$this->markTestSkipped();
} }
public function testSearchResultDomainSearch() public function testSearchResultDomainSearch()
{ {
//add a search service which will be supported //add a search service which will be supported
$this->addSearchService( $this->addSearchService(
$this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo' $this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo'
); );
$response = $this->search->getSearchResults('@foo default search'); $response = $this->search->getSearchResults('@foo default search');
$this->assertEquals(array( $this->assertEquals(array(
"I am domain foo" "I am domain foo"
), $response); ), $response);
} }
public function testSearchWithinSpecificSearchName() public function testSearchWithinSpecificSearchName()
{ {
//add a search service which will be supported //add a search service which will be supported
$this->addSearchService( $this->addSearchService(
$this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo' $this->createNonDefaultDomainSearchService("I am domain foo", 100, TRUE), 'foo'
); );
$response = $this->search->getResultByName('@foo search', 'foo'); $response = $this->search->getResultByName('@foo search', 'foo');
$this->assertEquals('I am domain foo', $response); $this->assertEquals('I am domain foo', $response);
} }
/** /**
* @expectedException \Chill\MainBundle\Search\ParsingException * @expectedException \Chill\MainBundle\Search\ParsingException
*/ */
public function testSearchWithinSpecificSearchNameInConflictWithSupport() public function testSearchWithinSpecificSearchNameInConflictWithSupport()
{ {
$response = $this->search->getResultByName('@foo default search', 'bar'); $response = $this->search->getResultByName('@foo default search', 'bar');
} }
/** /**
* shortcut for executing parse method * shortcut for executing parse method
* *
* @param unknown $pattern * @param unknown $pattern
* @return string[] * @return string[]
*/ */
@ -250,12 +249,12 @@ class SearchProviderTest extends TestCase
{ {
return $this->search->parse($pattern); return $this->search->parse($pattern);
} }
/** /**
* Add a search service to the chill.main.search_provider * Add a search service to the chill.main.search_provider
* *
* Useful for mocking the SearchInterface * Useful for mocking the SearchInterface
* *
* @param SearchInterface $search * @param SearchInterface $search
* @param string $name * @param string $name
*/ */
@ -264,52 +263,52 @@ class SearchProviderTest extends TestCase
$this->search $this->search
->addSearchService($search, $name); ->addSearchService($search, $name);
} }
private function createDefaultSearchService($result, $order) private function createDefaultSearchService($result, $order)
{ {
$mock = $this $mock = $this
->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch'); ->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch');
//set the mock as default //set the mock as default
$mock->expects($this->any()) $mock->expects($this->any())
->method('isActiveByDefault') ->method('isActiveByDefault')
->will($this->returnValue(TRUE)); ->will($this->returnValue(TRUE));
$mock->expects($this->any()) $mock->expects($this->any())
->method('getOrder') ->method('getOrder')
->will($this->returnValue($order)); ->will($this->returnValue($order));
//set the return value //set the return value
$mock->expects($this->any()) $mock->expects($this->any())
->method('renderResult') ->method('renderResult')
->will($this->returnValue($result)); ->will($this->returnValue($result));
return $mock; return $mock;
} }
private function createNonDefaultDomainSearchService($result, $order, $domain) private function createNonDefaultDomainSearchService($result, $order, $domain)
{ {
$mock = $this $mock = $this
->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch'); ->getMockForAbstractClass('Chill\MainBundle\Search\AbstractSearch');
//set the mock as default //set the mock as default
$mock->expects($this->any()) $mock->expects($this->any())
->method('isActiveByDefault') ->method('isActiveByDefault')
->will($this->returnValue(FALSE)); ->will($this->returnValue(FALSE));
$mock->expects($this->any()) $mock->expects($this->any())
->method('getOrder') ->method('getOrder')
->will($this->returnValue($order)); ->will($this->returnValue($order));
$mock->expects($this->any()) $mock->expects($this->any())
->method('supports') ->method('supports')
->will($this->returnValue($domain)); ->will($this->returnValue($domain));
//set the return value //set the return value
$mock->expects($this->any()) $mock->expects($this->any())
->method('renderResult') ->method('renderResult')
->will($this->returnValue($result)); ->will($this->returnValue($result));
return $mock; return $mock;
} }
} }

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

@ -0,0 +1,88 @@
<?php
namespace Serializer\Normalizer;
use Chill\MainBundle\Serializer\Normalizer\DateNormalizer;
use Prophecy\Prophet;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class DateNormalizerTest extends KernelTestCase
{
private Prophet $prophet;
public function setUp()
{
$this->prophet = new Prophet();
}
public function testSupports()
{
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(new \DateTime(), 'json'));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(new \DateTimeImmutable(), 'json'));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(new \DateTime(), 'docgen'));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(new \DateTimeImmutable(), 'docgen'));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(null, 'docgen', ['docgen:expects' => \DateTimeImmutable::class]));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(null, 'docgen', ['docgen:expects' => \DateTimeInterface::class]));
$this->assertTrue($this->buildDateNormalizer()->supportsNormalization(null, 'docgen', ['docgen:expects' => \DateTime::class]));
$this->assertFalse($this->buildDateNormalizer()->supportsNormalization(new \stdClass(), 'docgen'));
$this->assertFalse($this->buildDateNormalizer()->supportsNormalization(new \DateTime(), 'xml'));
}
/**
* @dataProvider generateDataNormalize
*/
public function testNormalize($expected, $date, $format, $locale, $msg)
{
$this->assertEquals($expected, $this->buildDateNormalizer($locale)->normalize($date, $format, []), $msg);
}
private function buildDateNormalizer(string $locale = null): DateNormalizer
{
$requestStack = $this->prophet->prophesize(RequestStack::class);
$parameterBag = new ParameterBag();
$parameterBag->set('kernel.default_locale', 'fr');
if ($locale === null) {
$requestStack->getCurrentRequest()->willReturn(null);
} else {
$request = $this->prophet->prophesize(Request::class);
$request->getLocale()->willReturn($locale);
$requestStack->getCurrentRequest()->willReturn($request->reveal());
}
return new DateNormalizer($requestStack->reveal(), $parameterBag);
}
public function generateDataNormalize()
{
$datetime = \DateTime::createFromFormat('Y-m-d H:i:sO', '2021-06-05 15:05:01+02:00');
$date = \DateTime::createFromFormat('Y-m-d H:i:sO', '2021-06-05 00:00:00+02:00');
yield [
['datetime' => '2021-06-05T15:05:01+0200'],
$datetime, 'json', null, 'simple normalization to json'
];
yield [
['long' => '5 juin 2021', 'short' => '05/06/2021'],
$date, 'docgen', 'fr', 'normalization to docgen for a date, with current request'
];
yield [
['long' => '5 juin 2021', 'short' => '05/06/2021'],
$date, 'docgen', null, 'normalization to docgen for a date, without current request'
];
yield [
['long' => '5 juin 2021 à 15:05', 'short' => '05/06/2021 15:05'],
$datetime, 'docgen', null, 'normalization to docgen for a datetime, without current request'
];
yield [
['long' => '', 'short' => ''],
null, 'docgen', null, 'normalization to docgen for a null datetime'
];
}
}

View File

@ -128,4 +128,16 @@ services:
Chill\MainBundle\Form\DataTransform\AddressToIdDataTransformer: ~ Chill\MainBundle\Form\DataTransform\AddressToIdDataTransformer: ~
Chill\MainBundle\Form\DataTransform\AddressToIdDataTransformer:
autoconfigure: true
autowire: true
Chill\MainBundle\Form\LocationFormType:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\UserCurrentLocationType:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\Type\LocationFormType: ~ Chill\MainBundle\Form\Type\LocationFormType: ~

View File

@ -7,8 +7,8 @@ services:
resource: '../../Routing/MenuBuilder' resource: '../../Routing/MenuBuilder'
Chill\MainBundle\Routing\MenuBuilder\UserMenuBuilder: Chill\MainBundle\Routing\MenuBuilder\UserMenuBuilder:
arguments: autowire: true
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface' autoconfigure: true
tags: tags:
- { name: 'chill.menu_builder' } - { name: 'chill.menu_builder' }

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,33 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add current location to User entity
*/
final class Version20211116162847 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add current location to User entity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD currentLocation_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E93C219753 FOREIGN KEY (currentLocation_id) REFERENCES chill_main_location (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_1483A5E93C219753 ON users (currentLocation_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP CONSTRAINT FK_1483A5E93C219753');
$this->addSql('DROP INDEX IDX_1483A5E93C219753');
$this->addSql('ALTER TABLE users DROP currentLocation_id');
}
}

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.
@ -176,6 +180,13 @@ Flags: Drapeaux
# admin section for users jobs # admin section for users jobs
User jobs: Métiers User jobs: Métiers
# user page for current location
Current location: Localisation actuelle
Edit my current location: Éditer ma localisation actuelle
Change current location: Changer ma localisation actuelle
Set a location: Indiquer une localisation
Current location successfully updated: Localisation actuelle mise à jour
Pick a location: Choisir un lieu
#admin section for circles (old: scopes) #admin section for circles (old: scopes)
List circles: Cercles List circles: Cercles
@ -190,7 +201,7 @@ Location: Localisation
Location type list: Liste des types de localisation Location type list: Liste des types de localisation
Create a new location type: Créer un nouveau type de localisation Create a new location type: Créer un nouveau type de localisation
Available for users: Disponible aux utilisateurs Available for users: Disponible aux utilisateurs
Address required: Adresse requise? Address required: Adresse requise?
Contact data: Données de contact? Contact data: Données de contact?
optional: optionnel optional: optionnel
required: requis required: requis
@ -200,8 +211,7 @@ Location list: Liste des localisations
Location type: Type de localisation Location type: Type de localisation
Phonenumber1: Numéro de téléphone Phonenumber1: Numéro de téléphone
Phonenumber2: Autre numéro de téléphone Phonenumber2: Autre numéro de téléphone
Configure location: Configuration des localisations Configure location and location type: Configuration des localisations
Configure location type: Configuration des types de localisations
# circles / scopes # circles / scopes
Choose the circle: Choisir le cercle Choose the circle: Choisir le cercle

View File

@ -0,0 +1,35 @@
<?php
namespace Chill\PersonBundle\DataFixtures\Helper;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
trait RandomPersonHelperTrait
{
private ?int $nbOfPersons = null;
protected function getRandomPerson(EntityManagerInterface $em): Person
{
$qb = $em->createQueryBuilder();
$qb
->from(Person::class, 'p')
;
if (null === $this->nbOfPersons) {
$this->nbOfPersons = $qb
->select('COUNT(p)')
->getQuery()
->getSingleScalarResult()
;
}
return $qb
->select('p')
->setMaxResults(1)
->setFirstResult(\random_int(0, $this->nbOfPersons))
->getQuery()
->getSingleResult()
;
}
}

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

@ -1139,11 +1139,11 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
public function getGroupSequence() public function getGroupSequence()
{ {
if ($this->getStep() == self::STEP_DRAFT) { if ($this->getStep() == self::STEP_DRAFT)
{
return [[self::STEP_DRAFT]]; return [[self::STEP_DRAFT]];
} } elseif ($this->getStep() == self::STEP_CONFIRMED)
{
if ($this->getStep() == self::STEP_CONFIRMED) {
return [[self::STEP_DRAFT, self::STEP_CONFIRMED]]; return [[self::STEP_DRAFT, self::STEP_CONFIRMED]];
} }

View File

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

View File

@ -37,20 +37,20 @@ class MaritalStatus
* @ORM\Id() * @ORM\Id()
* @ORM\Column(type="string", length=7) * @ORM\Column(type="string", length=7)
*/ */
private $id; private ?string $id;
/** /**
* @var string array * @var string array
* @ORM\Column(type="json") * @ORM\Column(type="json")
*/ */
private $name; private array $name;
/** /**
* Get id * Get id
* *
* @return string * @return string
*/ */
public function getId() public function getId(): string
{ {
return $this->id; return $this->id;
} }
@ -61,7 +61,7 @@ class MaritalStatus
* @param string $id * @param string $id
* @return MaritalStatus * @return MaritalStatus
*/ */
public function setId($id) public function setId(string $id): self
{ {
$this->id = $id; $this->id = $id;
return $this; return $this;
@ -73,7 +73,7 @@ class MaritalStatus
* @param string array $name * @param string array $name
* @return MaritalStatus * @return MaritalStatus
*/ */
public function setName($name) public function setName(array $name): self
{ {
$this->name = $name; $this->name = $name;
@ -85,7 +85,7 @@ class MaritalStatus
* *
* @return string array * @return string array
*/ */
public function getName() public function getName(): array
{ {
return $this->name; return $this->name;
} }

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
@ -213,7 +219,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"} * groups={"general", "creation"}
* ) * )
*/ */
private ?\DateTime $maritalStatusDate; private ?\DateTime $maritalStatusDate = null;
/** /**
* Comment on marital status * Comment on marital status
@ -246,7 +252,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's phonenumber * The person's phonenumber
* @var string * @var string
* *
* @ORM\Column(type="text", length=40, nullable=true) * @ORM\Column(type="text")
* @Assert\Regex( * @Assert\Regex(
* pattern="/^([\+{1}])([0-9\s*]{4,20})$/", * pattern="/^([\+{1}])([0-9\s*]{4,20})$/",
* groups={"general", "creation"} * groups={"general", "creation"}
@ -256,13 +262,13 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"} * groups={"general", "creation"}
* ) * )
*/ */
private $phonenumber = ''; private string $phonenumber = '';
/** /**
* The person's mobile phone number * The person's mobile phone number
* @var string * @var string
* *
* @ORM\Column(type="text", length=40, nullable=true) * @ORM\Column(type="text")
* @Assert\Regex( * @Assert\Regex(
* pattern="/^([\+{1}])([0-9\s*]{4,20})$/", * pattern="/^([\+{1}])([0-9\s*]{4,20})$/",
* groups={"general", "creation"} * groups={"general", "creation"}
@ -272,7 +278,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"} * groups={"general", "creation"}
* ) * )
*/ */
private $mobilenumber = ''; private string $mobilenumber = '';
/** /**
* @var Collection * @var Collection
@ -1088,9 +1094,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
/** /**
* Get nationality * Get nationality
* *
* @return Chill\MainBundle\Entity\Country * @return Country
*/ */
public function getNationality() public function getNationality(): ?Country
{ {
return $this->nationality; return $this->nationality;
} }
@ -1170,7 +1176,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* *
* @return string * @return string
*/ */
public function getPhonenumber() public function getPhonenumber(): string
{ {
return $this->phonenumber; return $this->phonenumber;
} }
@ -1193,7 +1199,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* *
* @return string * @return string
*/ */
public function getMobilenumber() public function getMobilenumber(): string
{ {
return $this->mobilenumber; return $this->mobilenumber;
} }

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
{ {
@ -107,7 +110,7 @@ class PersonPhone
{ {
$this->date = $date; $this->date = $date;
} }
public function isEmpty(): bool public function isEmpty(): bool
{ {
return empty($this->getDescription()) && empty($this->getPhonenumber()); return empty($this->getDescription()) && empty($this->getPhonenumber());

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);
if (NULL !== $lastname) { $orWhereSearchClause[] =
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname')) "(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ".
->setParameter('lastname', '%'.$lastname.'%'); "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
\array_push($orWhereSearchClauseArgs, $str, $str);
}
$query->andWhereClause(\implode(' OR ', $orWhereSearchClause),
$orWhereSearchClauseArgs);
} else {
$pertinence = ["1"];
$pertinenceArgs = [];
} }
$query
->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs)
;
if (NULL !== $birthdate) { 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 !== $firstname) {
if (NULL !== $birthdateAfter) { $query->andWhereClause(
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter')) "UNACCENT(LOWER(person.firstname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
->setParameter('birthdateafter', $birthdateAfter); [$firstname]
);
}
if (NULL !== $lastname) {
$query->andWhereClause(
"UNACCENT(LOWER(person.lastname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
[$lastname]
);
} }
if (NULL !== $birthdateBefore) { if (NULL !== $birthdateBefore) {
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore')) $query->andWhereClause(
->setParameter('birthdatebefore', $birthdateBefore); 'p.birthdate < ?::date',
[$birthdateBefore->format('Y-m-d')]
);
} }
if (NULL !== $birthdateAfter) {
if (NULL !== $gender) { $query->andWhereClause(
$qb->andWhere($qb->expr()->eq('p.gender', ':gender')) 'p.birthdate > ?::date',
->setParameter('gender', $gender); [$birthdateAfter->format('Y-m-d')]
);
} }
if (NULL !== $phonenumber) {
if (NULL !== $countryCode) { $query->andWhereClause(
try { "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'"
$country = $this->countryRepository->findOneBy(['countryCode' => $countryCode]); ,
} catch (NoResultException $ex) { [$phonenumber, $phonenumber, $phonenumber]
throw new ParsingException('The country code "'.$countryCode.'" ' );
. ', used in nationality, is unknow', 0, $ex); $query->setFromClause($query->getFromClause()." LEFT JOIN chill_person_phone pp ON pp.person_id = person.id");
} catch (NonUniqueResultException $e) {
throw $e;
}
$qb->andWhere($qb->expr()->eq('p.nationality', ':nationality'))
->setParameter('nationality', $country);
} }
if (null !== $city) {
$query->setFromClause($query->getFromClause()." ".
"JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id ".
"JOIN chill_main_address cma ON vcpca.address_id = cma.id ".
"JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id");
if (NULL !== $default) { foreach (\explode(" ", $city) as $cityStr) {
$grams = explode(' ', $default); $query->andWhereClause(
"(UNACCENT(LOWER(cmpc.label)) LIKE '%' || UNACCENT(LOWER(?)) || '%' OR cmpc.code LIKE '%' || UNACCENT(LOWER(?)) || '%')",
foreach($grams as $key => $gram) { [$cityStr, $city]
$qb->andWhere($qb->expr() );
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
->setParameter('default_'.$key, '%'.$gram.'%');
} }
} }
if (null !== $countryCode) {
return $qb; $query->setFromClause($query->getFromClause()." JOIN country ON person.nationality_id = country.id");
} $query->andWhereClause("country.countrycode = UPPER(?)", [$countryCode]);
}
private function addACLClauses(QueryBuilder $qb, string $personAlias): void if (null !== $gender) {
{ $query->andWhereClause("person.gender = ?", [$gender]);
// restrict center for security
$reachableCenters = $this->authorizationHelper
->getReachableCenters($this->security->getUser(), 'CHILL_PERSON_SEE');
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()
->in($personAlias.'.center', ':centers'),
$qb->expr()
->isNull($personAlias.'.center')
)
);
$qb->setParameter('centers', $reachableCenters);
}
/**
* Create a query for searching by similarity.
*
* The person alias is "sp".
*
* @param $pattern
* @return QueryBuilder
*/
public function createSimilarityQuery($pattern): QueryBuilder
{
$qb = $this->em->createQueryBuilder();
$qb->from(Person::class, 'sp');
$grams = explode(' ', $pattern);
foreach($grams as $key => $gram) {
$qb->andWhere('STRICT_WORD_SIMILARITY_OPS(:default_'.$key.', sp.fullnameCanonical) = TRUE')
->setParameter('default_'.$key, '%'.$gram.'%');
// remove the perfect matches
$qb->andWhere($qb->expr()
->notLike('sp.fullnameCanonical', 'UNACCENT(LOWER(:not_default_'.$key.'))'))
->setParameter('not_default_'.$key, '%'.$gram.'%');
} }
return $qb; 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

@ -60,6 +60,16 @@ h2.badge-title {
h3 { h3 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
//position: relative;
span {
display: none;
//position: absolute;
//top: 0;
//left: 0;
//transform: rotate(270deg);
//transform-origin: 0 0;
}
} }
span.title_action { span.title_action {
flex-grow: 1; flex-grow: 1;
@ -117,3 +127,36 @@ div.activity-list {
} }
} }
} }
/// AccompanyingCourse: HeaderSlider Carousel
div#header-accompanying_course-details {
button.carousel-control-prev,
button.carousel-control-next {
width: 8%;
opacity: inherit;
}
button.carousel-control-prev {
left: unset;
right: 0;
}
span.to-social-issues,
span.to-persons-associated {
display: inline-block;
border-radius: 15px;
width: 24px;
height: 24px;
box-shadow: 0 0 3px 1px grey;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
span.to-social-issues {
background-color: #4bafe8;
border-left: 12px solid #32749a;
}
span.to-persons-associated {
background-color: #16d9b4;
border-right: 12px solid #ffffff;
}
}

View File

@ -4,7 +4,7 @@
/// ///
@mixin chill_badge($color) { @mixin chill_badge($color) {
text-transform: capitalize !important; //text-transform: capitalize !important;
font-weight: 500 !important; font-weight: 500 !important;
border-left: 20px groove $color; border-left: 20px groove $color;
&:before { &:before {

View File

@ -22,20 +22,22 @@
<i>{{ $t('course.open_at') }}{{ $d(accompanyingCourse.openingDate.datetime, 'text') }}</i> <i>{{ $t('course.open_at') }}{{ $d(accompanyingCourse.openingDate.datetime, 'text') }}</i>
</span> </span>
<span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0"> <span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0">
<abbr :title="$t('course.referrer')">{{ $t('course.referrer') }}:</abbr> <b>{{ accompanyingCourse.user.username }}</b> <span class="item-key">{{ $t('course.referrer') }}:</span> <b>{{ accompanyingCourse.user.username }}</b>
</span> </span>
</span> </span>
</span> </span>
</teleport> </teleport>
<teleport to="#header-accompanying_course-details #banner-social-issues"> <teleport to="#header-accompanying_course-details #banner-social-issues">
<div class="col-12"> <social-issue
<social-issue v-for="issue in accompanyingCourse.socialIssues"
v-for="issue in accompanyingCourse.socialIssues" v-bind:key="issue.id"
v-bind:key="issue.id" v-bind:issue="issue">
v-bind:issue="issue"> </social-issue>
</social-issue> </teleport>
</div>
<teleport to="#header-accompanying_course-details #banner-persons-associated">
<persons-associated :accompanyingCourse="accompanyingCourse"></persons-associated>
</teleport> </teleport>
</template> </template>
@ -43,12 +45,14 @@
<script> <script>
import ToggleFlags from './Banner/ToggleFlags'; import ToggleFlags from './Banner/ToggleFlags';
import SocialIssue from './Banner/SocialIssue.vue'; import SocialIssue from './Banner/SocialIssue.vue';
import PersonsAssociated from './Banner/PersonsAssociated.vue';
export default { export default {
name: 'Banner', name: 'Banner',
components: { components: {
ToggleFlags, ToggleFlags,
SocialIssue SocialIssue,
PersonsAssociated
}, },
computed: { computed: {
accompanyingCourse() { accompanyingCourse() {

View File

@ -0,0 +1,75 @@
<template>
<span v-for="h in personsByHousehold()" :class="{ 'household': householdExists(h.id), 'no-household': !householdExists(h.id) }">
<a v-if="householdExists(h.id)" :href="householdLink(h.id)">
<i class="fa fa-home fa-fw text-light" :title="$t('persons_associated.show_household_number', { id: h.id })"></i>
</a>
<span v-for="person in h.persons" class="me-1">
<on-the-fly :type="person.type" :id="person.id" :buttonText="person.text" :displayBadge="'true' === 'true'" action="show"></on-the-fly>
</span>
</span>
</template>
<script>
import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly'
export default {
name: "PersonsAssociated",
components: {
OnTheFly
},
props: [ 'accompanyingCourse' ],
computed: {
participations() {
return this.accompanyingCourse.participations.filter(p => p.endDate === null)
},
persons() {
return this.participations.map(p => p.person)
},
resources() {
return this.accompanyingCourse.resources
},
requestor() {
return this.accompanyingCourse.requestor
}
},
methods: {
uniq(array) {
return [...new Set(array)]
},
personsByHousehold() {
let households = []
this.persons.forEach(p => { households.push(p.current_household_id) })
let personsByHousehold = []
this.uniq(households).forEach(h => {
personsByHousehold.push({
id: h !== null ? h : 0,
persons: this.persons.filter(p => p.current_household_id === h)
})
})
console.log(personsByHousehold)
return personsByHousehold
},
householdExists(id) {
return id !== 0
},
householdLink(id) {
return `/fr/person/household/${id}/summary`
}
}
}
</script>
<style lang="scss" scoped>
span.household {
display: inline-block;
border-top: 1px solid rgba(255, 255, 255, 0.3);
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
margin-right: 0.3em;
padding: 5px;
}
</style>

View File

@ -10,7 +10,7 @@
<div class="alert alert-warning"> <div class="alert alert-warning">
{{ $t('confirm.alert_validation') }} {{ $t('confirm.alert_validation') }}
<ul class="mt-2"> <ul class="mt-2">
<li v-for="k in validationKeys"> <li v-for="k in validationKeys" :key=k>
{{ $t(notValidMessages[k].msg) }} {{ $t(notValidMessages[k].msg) }}
<a :href="notValidMessages[k].anchor"> <a :href="notValidMessages[k].anchor">
<i class="fa fa-level-up fa-fw"></i> <i class="fa fa-level-up fa-fw"></i>
@ -83,7 +83,11 @@ export default {
}, },
location: { location: {
msg: 'confirm.location_not_valid', msg: 'confirm.location_not_valid',
anchor: '#section-20' // anchor: '#section-20'
},
origin: {
msg: 'confirm.origin_not_valid',
anchor: '#section-30'
}, },
socialIssue: { socialIssue: {
msg: 'confirm.socialIssue_not_valid', msg: 'confirm.socialIssue_not_valid',
@ -103,6 +107,7 @@ export default {
...mapGetters([ ...mapGetters([
'isParticipationValid', 'isParticipationValid',
'isSocialIssueValid', 'isSocialIssueValid',
'isOriginValid',
'isLocationValid', 'isLocationValid',
'validationKeys', 'validationKeys',
'isValidToBeConfirmed' 'isValidToBeConfirmed'

View File

@ -19,15 +19,18 @@
:options="options" :options="options"
@select="updateOrigin"> @select="updateOrigin">
</VueMultiselect> </VueMultiselect>
</div> </div>
<div v-if="!isOriginValid" class="alert alert-warning to-confirm">
{{ $t('origin.not_valid') }}
</div>
</div> </div>
</template> </template>
<script> <script>
import VueMultiselect from 'vue-multiselect'; import VueMultiselect from 'vue-multiselect';
import { getListOrigins } from '../api'; import { getListOrigins } from '../api';
import { mapState } from 'vuex'; import { mapState, mapGetters } from 'vuex';
export default { export default {
name: 'OriginDemand', name: 'OriginDemand',
@ -41,6 +44,9 @@ export default {
...mapState({ ...mapState({
value: state => state.accompanyingCourse.origin, value: state => state.accompanyingCourse.origin,
}), }),
...mapGetters([
'isOriginValid'
])
}, },
mounted() { mounted() {
this.getOptions(); this.getOptions();

View File

@ -5,7 +5,7 @@
addId : false, addId : false,
addEntity: false, addEntity: false,
addLink: false, addLink: false,
addHouseholdLink: true, addHouseholdLink: false,
addAltNames: true, addAltNames: true,
addAge : true, addAge : true,
hLevel : 3, hLevel : 3,
@ -20,14 +20,15 @@
v-if="hasCurrentHouseholdAddress" v-if="hasCurrentHouseholdAddress"
v-bind:person="participation.person"> v-bind:person="participation.person">
</button-location> </button-location>
<li v-if="participation.person.current_household_id">
<a class="btn btn-sm btn-chill-beige"
:href="getCurrentHouseholdUrl"
:title="$t('persons_associated.show_household_number', { id: participation.person.current_household_id })">
<i class="fa fa-fw fa-home"></i>
</a>
</li>
<li><on-the-fly :type="participation.person.type" :id="participation.person.id" action="show"></on-the-fly></li> <li><on-the-fly :type="participation.person.type" :id="participation.person.id" action="show"></on-the-fly></li>
<li><on-the-fly :type="participation.person.type" :id="participation.person.id" action="edit" @saveFormOnTheFly="saveFormOnTheFly"></on-the-fly></li> <li><on-the-fly :type="participation.person.type" :id="participation.person.id" action="edit" @saveFormOnTheFly="saveFormOnTheFly"></on-the-fly></li>
<!-- <li>
<button class="btn btn-delete"
:title="$t('action.delete')"
@click.prevent="$emit('remove', participation)">
</button>
</li> -->
<li> <li>
<button v-if="!participation.endDate" <button v-if="!participation.endDate"
class="btn btn-sm btn-remove" class="btn btn-sm btn-remove"
@ -100,6 +101,9 @@ export default {
}, },
getAccompanyingCourseReturnPath() { getAccompanyingCourseReturnPath() {
return `fr/parcours/${this.$store.state.accompanyingCourse.id}/edit#section-10`; return `fr/parcours/${this.$store.state.accompanyingCourse.id}/edit#section-10`;
},
getCurrentHouseholdUrl() {
return `/fr/person/household/${this.participation.person.current_household_id}/summary?returnPath=${this.getAccompanyingCourseReturnPath}`
} }
}, },
methods: { methods: {

View File

@ -19,16 +19,16 @@
@select="updateReferrer"> @select="updateReferrer">
</VueMultiselect> </VueMultiselect>
<template v-if="referrersSuggested.length > 0"> <template v-if="referrersSuggested.length > 0">
<ul> <ul class="list-unstyled">
<li v-for="u in referrersSuggested" @click="updateReferrer(u)"> <li v-for="u in referrersSuggested" @click="updateReferrer(u)">
<user-render-box-badge :user="u"></user-render-box-badge> <span class="badge bg-primary" style="cursor: pointer">
</li> <i class="fa fa-plus fa-fw text-success"></i>
<user-render-box-badge :user="u"></user-render-box-badge>
</span>
</li>
</ul> </ul>
</template> </template>
</div> </div>
<div> <div>

View File

@ -34,6 +34,7 @@ const appMessages = {
title: "Origine de la demande", title: "Origine de la demande",
label: "Origine de la demande", label: "Origine de la demande",
placeholder: "Renseignez l'origine de la demande", placeholder: "Renseignez l'origine de la demande",
not_valid: "Indiquez une origine de la demande",
}, },
persons_associated: { persons_associated: {
title: "Usagers concernés", title: "Usagers concernés",
@ -52,7 +53,7 @@ const appMessages = {
show_household_number: "Voir le ménage (n° {id})", show_household_number: "Voir le ménage (n° {id})",
show_household: "Voir le ménage", show_household: "Voir le ménage",
person_without_household_warning: "Certaines usagers n'appartiennent actuellement à aucun ménage. Renseignez leur appartenance dès que possible.", person_without_household_warning: "Certaines usagers n'appartiennent actuellement à aucun ménage. Renseignez leur appartenance dès que possible.",
update_household: "Modifier l'appartenance", update_household: "Renseigner l'appartenance",
participation_not_valid: "Sélectionnez ou créez au minimum 1 usager", participation_not_valid: "Sélectionnez ou créez au minimum 1 usager",
}, },
requestor: { requestor: {
@ -124,6 +125,7 @@ const appMessages = {
participation_not_valid: "sélectionnez au minimum 1 usager", participation_not_valid: "sélectionnez au minimum 1 usager",
socialIssue_not_valid: "sélectionnez au minimum une problématique sociale", socialIssue_not_valid: "sélectionnez au minimum une problématique sociale",
location_not_valid: "indiquez au minimum une localisation temporaire du parcours", location_not_valid: "indiquez au minimum une localisation temporaire du parcours",
origin_not_valid: "indiquez une origine de la demande",
set_a_scope: "indiquez au moins un service", set_a_scope: "indiquez au moins un service",
sure: "Êtes-vous sûr ?", sure: "Êtes-vous sûr ?",
sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !", sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !",

View File

@ -52,6 +52,9 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
isSocialIssueValid(state) { isSocialIssueValid(state) {
return state.accompanyingCourse.socialIssues.length > 0; return state.accompanyingCourse.socialIssues.length > 0;
}, },
isOriginValid(state) {
return state.accompanyingCourse.origin !== null;
},
isLocationValid(state) { isLocationValid(state) {
return state.accompanyingCourse.location !== null; return state.accompanyingCourse.location !== null;
}, },
@ -64,6 +67,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
if (!getters.isParticipationValid) { keys.push('participation'); } if (!getters.isParticipationValid) { keys.push('participation'); }
if (!getters.isLocationValid) { keys.push('location'); } if (!getters.isLocationValid) { keys.push('location'); }
if (!getters.isSocialIssueValid) { keys.push('socialIssue'); } if (!getters.isSocialIssueValid) { keys.push('socialIssue'); }
if (!getters.isOriginValid) { keys.push('origin'); }
if (!getters.isScopeValid) { keys.push('scopes'); } if (!getters.isScopeValid) { keys.push('scopes'); }
//console.log('getter keys', keys); //console.log('getter keys', keys);
return keys; return keys;

View File

@ -119,9 +119,9 @@
<div id="persons" class="action-row"> <div id="persons" class="action-row">
<h3>{{ $t('persons_involved') }}</h3> <h3>{{ $t('persons_involved') }}</h3>
<ul> <ul class="list-unstyled">
<li v-for="p in personsReachables" :key="p.id"> <li v-for="p in personsReachables" :key="p.id">
<input v-model="personsPicked" :value="p.id" type="checkbox"> <input v-model="personsPicked" :value="p.id" type="checkbox" class="me-2">
<person-render-box render="badge" :options="{}" :person="p"></person-render-box> <person-render-box render="badge" :options="{}" :person="p"></person-render-box>
</li> </li>
</ul> </ul>

View File

@ -96,14 +96,9 @@
<p class="chill-no-data-statement">{{ $t('renderbox.no_data') }}</p> <p class="chill-no-data-statement">{{ $t('renderbox.no_data') }}</p>
</li> </li>
<li v-if="person.center && options.addCenter"> <li v-if="person.centers !== undefined && person.centers.length > 0 && options.addCenter">
<i class="fa fa-li fa-long-arrow-right"></i> <i class="fa fa-li fa-long-arrow-right"></i>
<template v-if="person.center.type !== undefined"> <template v-for="c in person.centers">{{ c.name }}</template>
{{ person.center.name }}
</template>
<template v-else>
<template v-for="c in person.center">{{ c.name }}</template>
</template>
</li> </li>
<li v-else-if="options.addNoData"> <li v-else-if="options.addNoData">
<i class="fa fa-li fa-long-arrow-right"></i> <i class="fa fa-li fa-long-arrow-right"></i>

View File

@ -6,7 +6,7 @@
<a class="btn btn-sm btn-update change-icon" <a class="btn btn-sm btn-update change-icon"
href="{{ path('chill_person_accompanying_course_edit', { 'accompanying_period_id': accompanyingCourse.id, '_fragment': 'section-10' }) }}"> href="{{ path('chill_person_accompanying_course_edit', { 'accompanying_period_id': accompanyingCourse.id, '_fragment': 'section-10' }) }}">
<i class="fa fa-fw fa-crosshairs"></i> <i class="fa fa-fw fa-crosshairs"></i>
Corriger {{ 'fix it'|trans }}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -8,7 +8,7 @@
<a class="btn btn-sm btn-update change-icon" <a class="btn btn-sm btn-update change-icon"
href="{{ path('chill_person_accompanying_course_edit', { 'accompanying_period_id': accompanyingCourse.id, '_fragment': 'section-20' }) }}"> href="{{ path('chill_person_accompanying_course_edit', { 'accompanying_period_id': accompanyingCourse.id, '_fragment': 'section-20' }) }}">
<i class="fa fa-fw fa-crosshairs"></i> <i class="fa fa-fw fa-crosshairs"></i>
Corriger {{ 'fix it'|trans }}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -23,11 +23,28 @@
</div> </div>
<div id="header-accompanying_course-details" class="header-details"> <div id="header-accompanying_course-details" class="header-details">
<div class="container-xxl"> <div class="container-xxl">
<div <div class="row">
class="row justify-content-md-right">
{# vue teleport fragment here #} <div class="col-md-12 px-md-5 px-xxl-0">
<div class="col-md-10 ps-md-5 ps-xxl-0" id="banner-social-issues"></div> <div id="ACHeaderSlider" class="carousel carousel-dark slide" data-bs-ride="carousel">
<div class="carousel-inner">
<div class="carousel-item active">
{# vue teleport fragment here #}
<div id="banner-social-issues" class="col-11"></div>
</div>
<div class="carousel-item">
{# vue teleport fragment here #}
<div id="banner-persons-associated" class="col-11"></div>
</div>
</div>
<button class="carousel-control-prev justify-content-end visually-hidden" type="button" data-bs-target="#ACHeaderSlider" data-bs-slide="prev">
<span class="to-social-issues" title="{{ 'see social issues'|trans }}"></span>
</button>
<button class="carousel-control-next justify-content-end visually-hidden" type="button" data-bs-target="#ACHeaderSlider" data-bs-slide="next">
<span class="to-persons-associated" title="{{ 'see persons associated'|trans }}"></span>
</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -23,32 +23,6 @@
{% block content %} {% block content %}
<div class="accompanyingcourse-resume row"> <div class="accompanyingcourse-resume row">
<div class="associated-persons mb-5">
{% for h in participationsByHousehold %}
{% set householdClass = (h.household is not null) ? 'household-' ~ h.household.id : 'no-household alert alert-warning' %}
{% set householdTitle = (h.household is not null) ?
'household.Household number'|trans({'household_num': h.household.id }) : 'household.Never in any household'|trans %}
<span class="household {{ householdClass }}" title="{{ householdTitle }}">
{% if h.household is not null %}
<a href="{{ path('chill_person_household_summary', { 'household_id': h.household.id }) }}"
title="{{ 'household.Household number'|trans({'household_num': h.household.id }) }}"
><i class="fa fa-home fa-fw"></i></a>
{% endif %}
{% for p in h.members %}
{# include vue_onthefly component #}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: p.person.id },
action: 'show',
displayBadge: true,
buttonText: p.person|chill_entity_render_string
} %}
{% endfor %}
</span>
{% endfor %}
</div>
{% if 'DRAFT' == accompanyingCourse.step %} {% if 'DRAFT' == accompanyingCourse.step %}
<div class="col-md-6 warnings mb-5"> <div class="col-md-6 warnings mb-5">
{% include '@ChillPerson/AccompanyingCourse/_still_draft.html.twig' %} {% include '@ChillPerson/AccompanyingCourse/_still_draft.html.twig' %}
@ -83,7 +57,7 @@
</div> </div>
<div class="social-actions mb-5"> <div class="social-actions mb-5">
<h2 class="mb-3">{{ 'Last social actions'|trans }}</h2> <h2 class="mb-3 d-none">{{ 'Last social actions'|trans }}</h2>
{% include 'ChillPersonBundle:AccompanyingCourseWork:list_recent_by_accompanying_period.html.twig' with {'buttonText': false } %} {% include 'ChillPersonBundle:AccompanyingCourseWork:list_recent_by_accompanying_period.html.twig' with {'buttonText': false } %}
</div> </div>
@ -101,8 +75,7 @@
{% set accompanying_course_id = accompanyingCourse.id %} {% set accompanying_course_id = accompanyingCourse.id %}
{% endif %} {% endif %}
<h2 class="mb-3">{{ 'Last activities' |trans }}</h2> <h2 class="mb-3 d-none">{{ 'Last activities' |trans }}</h2>
{% include 'ChillActivityBundle:Activity:list_recent.html.twig' with { 'context': 'accompanyingCourse', 'no_action': true } %} {% include 'ChillActivityBundle:Activity:list_recent.html.twig' with { 'context': 'accompanyingCourse', 'no_action': true } %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,29 +1,33 @@
{% if works|length == 0 %}
<p class="chill-no-data-statement">{{ 'accompanying_course_work.Any work'|trans }}
<a class="btn btn-sm btn-create"
href="" title="TODO"></a>{# TODO link #}
</p>
{% endif %}
<div class="accompanying_course_work-list"> <div class="accompanying_course_work-list">
{% for w in works | slice(0,5) %} {% for w in works | slice(0,5) %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}"></a> <a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}"></a>
<h2 class="badge-title"> <h2 class="badge-title">
<span class="title_label">{{ 'accompanying_course_work.action'|trans }}</span> <span class="title_label">
<span class="title_action">{{ w.socialAction|chill_entity_render_string }} <span>{{ 'accompanying_course_work.action'|trans }}</span>
</span>
<span class="title_action">
{{ w.socialAction|chill_entity_render_string }}
<ul class="small_in_title"> <ul class="small_in_title">
<li> <li>
<abbr title="{{ 'accompanying_course_work.start_date'|trans }}">{{ 'accompanying_course_work.start_date'|trans ~ ' : ' }}</abbr> <span class="item-key">{{ 'accompanying_course_work.start_date'|trans ~ ' : ' }}</span>
{{ w.startDate|format_date('short') }} <b>{{ w.startDate|format_date('short') }}</b>
</li> </li>
{% if w.endDate %}
<li> <li>
<abbr title="{{ 'Last updated by'|trans }}">{{ 'Last updated by'|trans ~ ' : ' }}</abbr> <span class="item-key">{{ 'accompanying_course_work.end_date'|trans ~ ' : ' }}</span>
{{ w.updatedBy|chill_entity_render_box }}, {{ w.updatedAt|format_datetime('short', 'short') }} <b>{{ w.endDate|format_date('short') }}</b>
</li> </li>
{% endif %}
</ul> </ul>
<div class="metadata text-end" style="font-size: 60%">
{{ 'Last updated by'|trans }}
<span class="user">{{ w.updatedBy|chill_entity_render_box }}</span>:
<span class="date">{{ w.updatedAt|format_datetime('short', 'short') }}</span>
</div>
</span> </span>
</h2> </h2>
{% endfor %} {% endfor %}

View File

@ -148,10 +148,12 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</li> </li>
{% if options['addCenter'] and person|chill_resolve_center is not null %} {% if options['addCenter'] and person|chill_resolve_center|length > 0 %}
<li> <li>
<i class="fa fa-li fa-long-arrow-right"></i> <i class="fa fa-li fa-long-arrow-right"></i>
{{ person|chill_resolve_center.name }} {% for c in person|chill_resolve_center %}
{{ c.name|upper }}{% if not loop.last %}, {% endif %}
{% endfor %}
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -17,18 +17,14 @@
{%- endif -%} {%- endif -%}
</div> </div>
<div class="text-md-end"> <div class="text-md-end">
{% if person|chill_resolve_center is not null %} {% if person|chill_resolve_center|length > 0 %}
<span class="open_sansbold"> <span class="open_sansbold">
{{ 'Center'|trans|upper}} : {{ 'Center'|trans|upper}} :
</span> </span>
{% if person|chill_resolve_center is iterable %} {% for c in person|chill_resolve_center %}
{% for c in person|chill_resolve_center %} {{ c.name|upper }}{% if not loop.last %}, {% endif %}
{{ c.name|upper }}{% if not loop.last %}, {% endif %} {% endfor %}
{% endfor %}
{% else %}
{{ person|chill_resolve_center.name|upper }}
{% endif %}
{%- endif -%} {%- endif -%}
</div> </div>

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>
<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>
</li>
{% endif %}
<li> <li>
<a href="{{ path('chill_person_accompanying_course_new', { 'person_id': [ person.id ]}) }}" <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>
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' %}
{% if acp.requestorPerson == person %} <div class="is-draft">
<span class="as-requestor badge bg-info" title="{{ 'Requestor'|trans|e('html_attr') }}"> <span class="course-draft badge bg-secondary" title="{{ 'course.draft'|trans }}">{{ 'course.draft'|trans }}</span>
</div>
{% endif %}
{% if acp.requestorPerson == person %}
<div>
<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>
{% endif %} </div>
{% 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

@ -0,0 +1,116 @@
<?php
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\DocGeneratorBundle\Serializer\Helper\NormalizeNullValueHelper;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Symfony\Component\Serializer\Exception\CircularReferenceException;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class PersonDocGenNormalizer implements
ContextAwareNormalizerInterface,
NormalizerAwareInterface
{
use NormalizerAwareTrait;
private PersonRender $personRender;
private TranslatorInterface $translator;
private TranslatableStringHelper $translatableStringHelper;
/**
* @param PersonRender $personRender
* @param TranslatorInterface $translator
* @param TranslatableStringHelper $translatableStringHelper
*/
public function __construct(
PersonRender $personRender,
TranslatorInterface $translator,
TranslatableStringHelper $translatableStringHelper
) {
$this->personRender = $personRender;
$this->translator = $translator;
$this->translatableStringHelper = $translatableStringHelper;
}
public function normalize($person, string $format = null, array $context = [])
{
/** @var Person $person */
$dateContext = $context;
$dateContext['docgen:expects'] = \DateTimeInterface::class;
if (null === $person) {
return $this->normalizeNullValue($format, $context);
}
return [
'firstname' => $person->getFirstName(),
'lastname' => $person->getLastName(),
'altNames' => \implode(
', ',
\array_map(
function (PersonAltName $altName) {
return $altName->getLabel();
},
$person->getAltNames()->toArray()
)
),
'text' => $this->personRender->renderString($person, []),
'birthdate' => $this->normalizer->normalize($person->getBirthdate(), $format, $dateContext),
'deathdate' => $this->normalizer->normalize($person->getDeathdate(), $format, $dateContext),
'gender' => $this->translator->trans($person->getGender()),
'maritalStatus' => null !== ($ms = $person->getMaritalStatus()) ? $this->translatableStringHelper->localize($ms->getName()) : '',
'maritalStatusDate' => $this->normalizer->normalize($person->getMaritalStatusDate(), $format, $dateContext),
'email' => $person->getEmail(),
'firstPhoneNumber' => $person->getPhonenumber() ?? $person->getMobilenumber(),
'fixPhoneNumber' => $person->getPhonenumber(),
'mobilePhoneNumber' => $person->getMobilenumber(),
'nationality' => null !== ($c = $person->getNationality()) ? $this->translatableStringHelper->localize($c->getName()) : '',
'placeOfBirth' => $person->getPlaceOfBirth(),
'memo' => $person->getMemo(),
'numberOfChildren' => (string) $person->getNumberOfChildren(),
];
}
private function normalizeNullValue(string $format, array $context)
{
$normalizer = new NormalizeNullValueHelper($this->normalizer);
$attributes = [
'firstname', 'lastname', 'altNames', 'text',
'birthdate' => \DateTimeInterface::class,
'deathdate' => \DateTimeInterface::class,
'gender', 'maritalStatus',
'maritalStatusDate' => \DateTimeInterface::class,
'email', 'firstPhoneNumber', 'fixPhoneNumber', 'mobilePhoneNumber', 'nationality',
'placeOfBirth', 'memo', 'numberOfChildren'
];
return $normalizer->normalize($attributes, $format, $context);
}
public function supportsNormalization($data, string $format = null, array $context = [])
{
if ($format !== 'docgen') {
return false;
}
return
$data instanceof Person
|| (
\array_key_exists('docgen:expects', $context)
&& $context['docgen:expects'] === Person::class
);
}
}

View File

@ -20,6 +20,7 @@ namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
@ -39,7 +40,7 @@ use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
* Serialize a Person entity * Serialize a Person entity
* *
*/ */
class PersonNormalizer implements class PersonJsonNormalizer implements
NormalizerInterface, NormalizerInterface,
NormalizerAwareInterface, NormalizerAwareInterface,
DenormalizerInterface, DenormalizerInterface,
@ -49,7 +50,7 @@ class PersonNormalizer implements
private PersonRepository $repository; private PersonRepository $repository;
private CenterResolverDispatcher $centerResolverDispatcher; private CenterResolverManagerInterface $centerResolverManager;
use NormalizerAwareTrait; use NormalizerAwareTrait;
@ -60,11 +61,11 @@ class PersonNormalizer implements
public function __construct( public function __construct(
ChillEntityRenderExtension $render, ChillEntityRenderExtension $render,
PersonRepository $repository, PersonRepository $repository,
CenterResolverDispatcher $centerResolverDispatcher CenterResolverManagerInterface $centerResolverManager
) { ) {
$this->render = $render; $this->render = $render;
$this->repository = $repository; $this->repository = $repository;
$this->centerResolverDispatcher = $centerResolverDispatcher; $this->centerResolverManager = $centerResolverManager;
} }
public function normalize($person, string $format = null, array $context = []) public function normalize($person, string $format = null, array $context = [])
@ -79,15 +80,15 @@ class PersonNormalizer implements
'text' => $this->render->renderString($person), 'text' => $this->render->renderString($person),
'firstName' => $person->getFirstName(), 'firstName' => $person->getFirstName(),
'lastName' => $person->getLastName(), 'lastName' => $person->getLastName(),
'birthdate' => $this->normalizer->normalize($person->getBirthdate()), 'birthdate' => $this->normalizer->normalize($person->getBirthdate(), $format, $context),
'deathdate' => $this->normalizer->normalize($person->getDeathdate()), 'deathdate' => $this->normalizer->normalize($person->getDeathdate(), $format, $context),
'center' => $this->normalizer->normalize($this->centerResolverDispatcher->resolveCenter($person)), 'centers' => $this->normalizer->normalize($this->centerResolverManager->resolveCenters($person), $format, $context),
'phonenumber' => $person->getPhonenumber(), 'phonenumber' => $person->getPhonenumber(),
'mobilenumber' => $person->getMobilenumber(), 'mobilenumber' => $person->getMobilenumber(),
'altNames' => $this->normalizeAltNames($person->getAltNames()), 'altNames' => $this->normalizeAltNames($person->getAltNames()),
'gender' => $person->getGender(), 'gender' => $person->getGender(),
'current_household_address' => $this->normalizer->normalize($person->getCurrentHouseholdAddress()), 'current_household_address' => $this->normalizer->normalize($person->getCurrentHouseholdAddress(), $format, $context),
'current_household_id' => $household ? $this->normalizer->normalize($household->getId()) : null, 'current_household_id' => $household ? $this->normalizer->normalize($household->getId(), $format, $context) : null,
]; ];
} }
@ -105,7 +106,7 @@ class PersonNormalizer implements
public function supportsNormalization($data, string $format = null): bool public function supportsNormalization($data, string $format = null): bool
{ {
return $data instanceof Person; return $data instanceof Person && $format === 'json';
} }
public function denormalize($data, string $type, string $format = null, array $context = []) public function denormalize($data, string $type, string $format = null, array $context = [])
@ -128,45 +129,48 @@ class PersonNormalizer implements
$person = new Person(); $person = new Person();
} }
$properties = ['firstName', 'lastName', 'phonenumber', 'mobilenumber', 'gender']; foreach (['firstName', 'lastName', 'phonenumber', 'mobilenumber', 'gender',
'birthdate', 'deathdate', 'center']
as $item) {
$properties = array_filter( if (!\array_key_exists($item, $data)) {
$properties, continue;
static fn (string $property): bool => array_key_exists($property, $data)
);
foreach ($properties as $item) {
$callable = [$person, sprintf('set%s', ucfirst($item))];
if (is_callable($callable)) {
$closure = \Closure::fromCallable($callable);
$closure($data[$item]);
} }
}
$propertyToClassMapping = [ switch ($item) {
'birthdate' => \DateTime::class, case 'firstName':
'deathdate' => \DateTime::class, $person->setFirstName($data[$item]);
'center' => Center::class, break;
]; case 'lastName':
$person->setLastName($data[$item]);
$propertyToClassMapping = array_filter( break;
$propertyToClassMapping, case 'phonenumber':
static fn (string $item): bool => array_key_exists($item, $data) $person->setPhonenumber($data[$item]);
); break;
case 'mobilenumber':
foreach ($propertyToClassMapping as $item => $class) { $person->setMobilenumber($data[$item]);
$object = $this->denormalizer->denormalize($data[$item], $class, $format, $context); break;
case 'gender':
if ($object instanceof $class) { $person->setGender($data[$item]);
$callable = [$object, sprintf('set%s', ucfirst($item))]; break;
case 'birthdate':
if (is_callable($callable)) { $object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
$closure = \Closure::fromCallable($callable); if ($object instanceof \DateTime) {
$person->setBirthdate($object);
$closure($object); }
} break;
case 'deathdate':
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
if ($object instanceof \DateTime) {
$person->setDeathdate($object);
}
break;
case 'center':
$object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context);
$person->setCenter($object);
break;
default:
throw new \LogicException("item not defined: $item");
} }
} }

View File

@ -0,0 +1,71 @@
<?php
namespace Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\Serializer\Normalizer\PersonDocGenNormalizer;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class PersonDocGenNormalizerTest extends KernelTestCase
{
private NormalizerInterface $normalizer;
protected function setUp()
{
self::bootKernel();
$this->normalizer = self::$container->get(NormalizerInterface::class);
}
/**
* @dataProvider generateData
*/
public function testNormalize(?Person $person, $expected, $msg)
{
$normalized = $this->normalizer->normalize($person, 'docgen', ['docgen:expects' => Person::class]);
$this->assertEquals($expected, $normalized, $msg);
}
public function generateData()
{
$person = new Person();
$person
->setFirstName('Renaud')
->setLastName('Mégane')
;
$expected = \array_merge(
self::BLANK, ['firstname' => 'Renaud', 'lastname' => 'Mégane',
'text' => 'Renaud Mégane']
);
yield [$person, $expected, 'partial normalization for a person'];
yield [null, self::BLANK, 'normalization for a null person'];
}
private const BLANK = [
'firstname' => '',
'lastname' => '',
'altNames' => '',
'text' => '',
'birthdate' => ['short' => '', 'long' => ''],
'deathdate' => ['short' => '', 'long' => ''],
'gender' => '',
'maritalStatus' => '',
'maritalStatusDate' => ['short' => '', 'long' => ''],
'email' => '',
'firstPhoneNumber' => '',
'fixPhoneNumber' => '',
'mobilePhoneNumber' => '',
'nationality' => '',
'placeOfBirth' => '',
'memo' => '',
'numberOfChildren' => ''
];
}

Some files were not shown because too many files have changed in this diff Show More