diff --git a/composer.json b/composer.json index a2aa29a5a..747747290 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,8 @@ "symfony/web-profiler-bundle": "^5.0" }, "config": { - "bin-dir": "bin" + "bin-dir": "bin", + "vendor-dir": "tests/app/vendor" }, "autoload": { "psr-4": { diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index fe515918a..0988eedfe 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -22,6 +22,9 @@ namespace Chill\ActivityBundle\Controller; +use Chill\ActivityBundle\Repository\ActivityACLAwareRepository; +use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface; +use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; @@ -53,12 +56,16 @@ class ActivityController extends AbstractController protected SerializerInterface $serializer; + protected ActivityACLAwareRepositoryInterface $activityACLAwareRepository; + public function __construct( + ActivityACLAwareRepositoryInterface $activityACLAwareRepository, EventDispatcherInterface $eventDispatcher, AuthorizationHelper $authorizationHelper, LoggerInterface $logger, SerializerInterface $serializer ) { + $this->activityACLAwareRepository = $activityACLAwareRepository; $this->eventDispatcher = $eventDispatcher; $this->authorizationHelper = $authorizationHelper; $this->logger = $logger; @@ -77,13 +84,9 @@ class ActivityController extends AbstractController [$person, $accompanyingPeriod] = $this->getEntity($request); if ($person instanceof Person) { - $reachableScopes = $this->authorizationHelper - ->getReachableCircles($this->getUser(), new Role('CHILL_ACTIVITY_SEE'), - $person->getCenter()); - - $activities = $em->getRepository(Activity::class) - ->findByPersonImplied($person, $reachableScopes) - ; + $this->denyAccessUnlessGranted(ActivityVoter::SEE, $person); + $activities = $this->activityACLAwareRepository + ->findByPerson($person, ActivityVoter::SEE, 0, null); $event = new PrivacyEvent($person, array( 'element_class' => Activity::class, @@ -93,10 +96,10 @@ class ActivityController extends AbstractController $view = 'ChillActivityBundle:Activity:listPerson.html.twig'; } elseif ($accompanyingPeriod instanceof AccompanyingPeriod) { - $activities = $em->getRepository('ChillActivityBundle:Activity')->findBy( - ['accompanyingPeriod' => $accompanyingPeriod], - ['date' => 'DESC'], - ); + $this->denyAccessUnlessGranted(ActivityVoter::SEE, $accompanyingPeriod); + + $activities = $this->activityACLAwareRepository + ->findByAccompanyingPeriod($accompanyingPeriod, ActivityVoter::SEE); $view = 'ChillActivityBundle:Activity:listAccompanyingCourse.html.twig'; } @@ -238,7 +241,7 @@ class ActivityController extends AbstractController if (!$entity) { throw $this->createNotFoundException('Unable to find Activity entity.'); } - + if (null !== $accompanyingPeriod) { $entity->personsAssociated = $entity->getPersonsAssociated(); $entity->personsNotAssociated = $entity->getPersonsNotAssociated(); diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php index b198875c5..b498a6090 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php @@ -23,6 +23,8 @@ namespace Chill\ActivityBundle\Repository; use Chill\ActivityBundle\Entity\Activity; +use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; +use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\ActivityBundle\Repository\ActivityRepository; use Chill\ActivityBundle\Security\Authorization\ActivityVoter; @@ -33,9 +35,10 @@ use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Role\Role; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Security\Core\Security; -final class ActivityACLAwareRepository +final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface { private AuthorizationHelper $authorizationHelper; @@ -45,16 +48,63 @@ final class ActivityACLAwareRepository private EntityManagerInterface $em; + private Security $security; + + private CenterResolverDispatcher $centerResolverDispatcher; + public function __construct( AuthorizationHelper $authorizationHelper, + CenterResolverDispatcher $centerResolverDispatcher, TokenStorageInterface $tokenStorage, ActivityRepository $repository, - EntityManagerInterface $em + EntityManagerInterface $em, + Security $security ) { $this->authorizationHelper = $authorizationHelper; + $this->centerResolverDispatcher = $centerResolverDispatcher; $this->tokenStorage = $tokenStorage; $this->repository = $repository; $this->em = $em; + $this->security = $security; + } + + /** + * @param Person $person + * @param string $role + * @param int|null $start + * @param int|null $limit + * @param array $orderBy + * @return array|Activity[] + */ + public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array + { + $user = $this->security->getUser(); + $center = $this->centerResolverDispatcher->resolveCenter($person); + if (0 === count($orderBy)) { + $orderBy = ['date' => 'DESC']; + } + + $reachableScopes = $this->authorizationHelper + ->getReachableCircles($user, $role, $center); + + return $this->em->getRepository(Activity::class) + ->findByPersonImplied($person, $reachableScopes, $orderBy, $limit, $start); + ; + } + + public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array + { + $user = $this->security->getUser(); + $center = $this->centerResolverDispatcher->resolveCenter($period); + if (0 === count($orderBy)) { + $orderBy = ['date' => 'DESC']; + } + + $scopes = $this->authorizationHelper + ->getReachableCircles($user, $role, $center); + + return $this->em->getRepository(Activity::class) + ->findByAccompanyingPeriod($period, $scopes, true, $limit, $start, $orderBy); } public function queryTimelineIndexer(string $context, array $args = []): array @@ -81,7 +131,7 @@ final class ActivityACLAwareRepository $metadataActivity = $this->em->getClassMetadata(Activity::class); $metadataPerson = $this->em->getClassMetadata(Person::class); $associationMapping = $metadataActivity->getAssociationMapping('person'); - + return $metadataActivity->getTableName().' JOIN ' .$metadataPerson->getTableName().' ON ' .$metadataPerson->getTableName().'.'. @@ -95,7 +145,7 @@ final class ActivityACLAwareRepository { $where = ''; $parameters = []; - + $metadataActivity = $this->em->getClassMetadata(Activity::class); $metadataPerson = $this->em->getClassMetadata(Person::class); $activityToPerson = $metadataActivity->getAssociationMapping('person')['joinColumns'][0]['name']; @@ -105,20 +155,20 @@ final class ActivityACLAwareRepository // acls: $role = new Role(ActivityVoter::SEE); - $reachableCenters = $this->authorizationHelper->getReachableCenters($this->tokenStorage->getToken()->getUser(), + $reachableCenters = $this->authorizationHelper->getReachableCenters($this->tokenStorage->getToken()->getUser(), $role); - + if (count($reachableCenters) === 0) { // insert a dummy condition return 'FALSE = TRUE'; } - if ($context === 'person') { - // we start with activities having the person_id linked to person + if ($context === 'person') { + // we start with activities having the person_id linked to person $where .= sprintf('%s = ? AND ', $activityToPerson); $parameters[] = $person->getId(); } - + // we add acl (reachable center and scopes) $where .= '('; // first loop for the for centers $centersI = 0; // like centers#i @@ -131,7 +181,7 @@ final class ActivityACLAwareRepository $reachableScopes = $this->authorizationHelper->getReachableScopes($this->tokenStorage->getToken()->getUser(), $role, $center); // we get the ids for those scopes $reachablesScopesId = array_map( - function(Scope $scope) { return $scope->getId(); }, + function(Scope $scope) { return $scope->getId(); }, $reachableScopes ); @@ -162,7 +212,7 @@ final class ActivityACLAwareRepository } // close loop for centers $where .= ')'; - + return [$where, $parameters]; } diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php new file mode 100644 index 000000000..0b646f7c5 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php @@ -0,0 +1,19 @@ + 'DESC'], $limit = 100, $offset = 0) + /** + * @param $person + * @param array $scopes + * @param string[] $orderBy + * @param int $limit + * @param int $offset + * @return array|Activity[] + */ + public function findByPersonImplied(Person $person, array $scopes, ?array $orderBy = [ 'date' => 'DESC'], ?int $limit = 100, ?int $offset = 0): array { $qb = $this->createQueryBuilder('a'); $qb->select('a'); $qb - // TODO add acl - //->where($qb->expr()->in('a.scope', ':scopes')) - //->setParameter('scopes', $scopes) + ->where($qb->expr()->in('a.scope', ':scopes')) + ->setParameter('scopes', $scopes) ->andWhere( $qb->expr()->orX( $qb->expr()->eq('a.person', ':person'), @@ -61,7 +70,56 @@ class ActivityRepository extends ServiceEntityRepository $qb->addOrderBy('a.'.$k, $dir); } + $qb->setMaxResults($limit)->setFirstResult($offset); + return $qb->getQuery() ->getResult(); - } + } + + /** + * @param AccompanyingPeriod $period + * @param array $scopes + * @param int|null $limit + * @param int|null $offset + * @param array|string[] $orderBy + * @return array|Activity[] + */ + public function findByAccompanyingPeriod(AccompanyingPeriod $period, array $scopes, ?bool $allowNullScope = false, ?int $limit = 100, ?int $offset = 0, array $orderBy = ['date' => 'desc']): array + { + $qb = $this->createQueryBuilder('a'); + $qb->select('a'); + + if (!$allowNullScope) { + $qb + ->where($qb->expr()->in('a.scope', ':scopes')) + ->setParameter('scopes', $scopes) + ; + } else { + $qb + ->where( + $qb->expr()->orX( + $qb->expr()->in('a.scope', ':scopes'), + $qb->expr()->isNull('a.scope') + ) + ) + ->setParameter('scopes', $scopes) + ; + } + + $qb + ->andWhere( + $qb->expr()->eq('a.accompanyingPeriod', ':period') + ) + ->setParameter('period', $period) + ; + + foreach ($orderBy as $k => $dir) { + $qb->addOrderBy('a.'.$k, $dir); + } + + $qb->setMaxResults($limit)->setFirstResult($offset); + + return $qb->getQuery() + ->getResult(); + } } diff --git a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityVoter.php b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityVoter.php index 79cb6d852..cc9cecf52 100644 --- a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityVoter.php +++ b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityVoter.php @@ -19,6 +19,11 @@ namespace Chill\ActivityBundle\Security\Authorization; +use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface; +use Chill\MainBundle\Security\Authorization\VoterHelperInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; +use Chill\PersonBundle\Security\Authorization\PersonVoter; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Chill\MainBundle\Security\Authorization\AbstractChillVoter; @@ -28,11 +33,10 @@ use Chill\MainBundle\Entity\User; use Chill\ActivityBundle\Entity\Activity; use Chill\PersonBundle\Entity\Person; use Symfony\Component\Security\Core\Role\Role; +use Symfony\Component\Security\Core\Security; /** - * - * - * @author Julien Fastré + * Voter for Activity class */ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface { @@ -41,30 +45,37 @@ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn const SEE_DETAILS = 'CHILL_ACTIVITY_SEE_DETAILS'; const UPDATE = 'CHILL_ACTIVITY_UPDATE'; const DELETE = 'CHILL_ACTIVITY_DELETE'; + const FULL = 'CHILL_ACTIVITY_FULL'; - /** - * - * @var AuthorizationHelper - */ - protected $helper; + private const ALL = [ + self::CREATE, + self::SEE, + self::UPDATE, + self::DELETE, + self::SEE_DETAILS, + self::FULL + ]; - public function __construct(AuthorizationHelper $helper) - { - $this->helper = $helper; + protected VoterHelperInterface $voterHelper; + + protected Security $security; + + public function __construct( + Security $security, + VoterHelperFactoryInterface $voterHelperFactory + ) { + $this->security = $security; + $this->voterHelper = $voterHelperFactory->generate(self::class) + ->addCheckFor(Person::class, [self::SEE, self::CREATE]) + ->addCheckFor(AccompanyingPeriod::class, [self::SEE, self::CREATE]) + ->addCheckFor(Activity::class, self::ALL) + ->build(); } protected function supports($attribute, $subject) { - if ($subject instanceof Activity) { - return \in_array($attribute, $this->getAttributes()); - } elseif ($subject instanceof Person) { - return $attribute === self::SEE - || - $attribute === self::CREATE; - } else { - return false; - } + return $this->voterHelper->supports($attribute, $subject); } protected function voteOnAttribute($attribute, $subject, TokenInterface $token) @@ -72,32 +83,34 @@ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn if (!$token->getUser() instanceof User) { return false; } - - if ($subject instanceof Person) { - $centers = $this->helper->getReachableCenters($token->getUser(), new Role($attribute)); - - return \in_array($subject->getCenter(), $centers); + + if ($subject instanceof Activity) { + if ($subject->getPerson() instanceof Person) { + // the context is person: we must have the right to see the person + if (!$this->security->isGranted(PersonVoter::SEE, $subject->getPerson())) { + return false; + } + } elseif ($subject->getAccompanyingPeriod() instanceof AccompanyingPeriod) { + if (!$this->security->isGranted(AccompanyingPeriodVoter::SEE, $subject->getAccompanyingPeriod())) { + return false; + } + } else { + throw new \RuntimeException("could not determine context of activity"); + } } - - /* @var $subject Activity */ - return $this->helper->userHasAccess($token->getUser(), $subject, $attribute); - } - - private function getAttributes() - { - return [ self::CREATE, self::SEE, self::UPDATE, self::DELETE, - self::SEE_DETAILS ]; + + return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); } public function getRoles() { - return $this->getAttributes(); + return self::ALL; } public function getRolesWithoutScope() { - return array(); + return []; } diff --git a/src/Bundle/ChillActivityBundle/config/services.yaml b/src/Bundle/ChillActivityBundle/config/services.yaml index 86168101a..0a65e08c8 100644 --- a/src/Bundle/ChillActivityBundle/config/services.yaml +++ b/src/Bundle/ChillActivityBundle/config/services.yaml @@ -1,20 +1,4 @@ services: - chill.activity.security.authorization.activity_voter: - class: Chill\ActivityBundle\Security\Authorization\ActivityVoter - arguments: - - "@chill.main.security.authorization.helper" - tags: - - { name: security.voter } - - { name: chill.role } - - chill.activity.security.authorization.activity_stats_voter: - class: Chill\ActivityBundle\Security\Authorization\ActivityStatsVoter - arguments: - - "@chill.main.security.authorization.helper" - tags: - - { name: security.voter } - - { name: chill.role } - chill.activity.timeline: class: Chill\ActivityBundle\Timeline\TimelineActivityProvider @@ -38,3 +22,8 @@ services: autowire: true autoconfigure: true resource: '../Notification' + + Chill\ActivityBundle\Security\Authorization\: + resource: '../Security/Authorization/' + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillActivityBundle/config/services/controller.yaml b/src/Bundle/ChillActivityBundle/config/services/controller.yaml index 106b2c6e4..96ace0a64 100644 --- a/src/Bundle/ChillActivityBundle/config/services/controller.yaml +++ b/src/Bundle/ChillActivityBundle/config/services/controller.yaml @@ -1,8 +1,4 @@ services: Chill\ActivityBundle\Controller\ActivityController: - arguments: - $eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface' - $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' - $logger: '@chill.main.logger' - $serializer: '@Symfony\Component\Serializer\SerializerInterface' + autowire: true tags: ['controller.service_arguments'] diff --git a/src/Bundle/ChillActivityBundle/config/services/repositories.yaml b/src/Bundle/ChillActivityBundle/config/services/repositories.yaml index 2f0a9b83c..26bc58ac9 100644 --- a/src/Bundle/ChillActivityBundle/config/services/repositories.yaml +++ b/src/Bundle/ChillActivityBundle/config/services/repositories.yaml @@ -24,9 +24,7 @@ services: - '@Doctrine\Persistence\ManagerRegistry' Chill\ActivityBundle\Repository\ActivityACLAwareRepository: - arguments: - $tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface' - $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' - $repository: '@Chill\ActivityBundle\Repository\ActivityRepository' - $em: '@Doctrine\ORM\EntityManagerInterface' + autowire: true + autoconfigure: true + Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface: '@Chill\ActivityBundle\Repository\ActivityACLAwareRepository' diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/PersonDocumentVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/PersonDocumentVoter.php index 78429b439..c92971b8f 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/PersonDocumentVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/PersonDocumentVoter.php @@ -23,6 +23,7 @@ use Chill\MainBundle\Security\Authorization\AbstractChillVoter; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; use Chill\DocStoreBundle\Entity\PersonDocument; +use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\PersonBundle\Entity\Person; use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Security\Authorization\PersonVoter; @@ -42,30 +43,25 @@ class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHiera const UPDATE = 'CHILL_PERSON_DOCUMENT_UPDATE'; const DELETE = 'CHILL_PERSON_DOCUMENT_DELETE'; - /** - * @var AuthorizationHelper - */ - protected $authorizationHelper; + protected AuthorizationHelper $authorizationHelper; - /** - * @var AccessDecisionManagerInterface - */ - protected $accessDecisionManager; + protected AccessDecisionManagerInterface $accessDecisionManager; - /** - * @var LoggerInterface - */ - protected $logger; + protected LoggerInterface $logger; + + protected CenterResolverDispatcher $centerResolverDispatcher; public function __construct( AccessDecisionManagerInterface $accessDecisionManager, AuthorizationHelper $authorizationHelper, - LoggerInterface $logger + LoggerInterface $logger//, + //CenterResolverDispatcher $centerResolverDispatcher ) { $this->accessDecisionManager = $accessDecisionManager; $this->authorizationHelper = $authorizationHelper; $this->logger = $logger; + //$this->centerResolverDispatcher = $centerResolverDispatcher; } public function getRoles() @@ -78,17 +74,18 @@ class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHiera self::DELETE ]; } - + protected function supports($attribute, $subject) { if (\in_array($attribute, $this->getRoles()) && $subject instanceof PersonDocument) { return true; } - - if ($subject instanceof Person && $attribute === self::CREATE) { + + if ($subject instanceof Person + && \in_array($attribute, [self::CREATE, self::SEE])) { return true; } - + return false; } @@ -107,6 +104,8 @@ class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHiera return false; } + $center = $this->centerResolverDispatcher->resolveCenter($subject); + if ($subject instanceof PersonDocument) { return $this->authorizationHelper->userHasAccess($token->getUser(), $subject, $attribute); diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index ee9523273..91c58f68b 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -3,6 +3,11 @@ namespace Chill\MainBundle; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\MainBundle\Search\SearchInterface; +use Chill\MainBundle\Security\Authorization\ChillVoterInterface; +use Chill\MainBundle\Security\ProvideRoleInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverInterface; +use Chill\MainBundle\Security\Resolver\ScopeResolverInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass; @@ -27,6 +32,12 @@ class ChillMainBundle extends Bundle $container->registerForAutoconfiguration(LocalMenuBuilderInterface::class) ->addTag('chill.menu_builder'); + $container->registerForAutoconfiguration(ProvideRoleInterface::class) + ->addTag('chill.role'); + $container->registerForAutoconfiguration(CenterResolverInterface::class) + ->addTag('chill_main.center_resolver'); + $container->registerForAutoconfiguration(ScopeResolverInterface::class) + ->addTag('chill_main.scope_resolver'); $container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass()); diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 1637ee098..d0aada689 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -19,6 +19,10 @@ namespace Chill\MainBundle\DependencyInjection; +use Chill\MainBundle\Doctrine\DQL\STContains; +use Chill\MainBundle\Doctrine\DQL\StrictWordSimilarityOPS; +use Chill\MainBundle\Entity\UserJob; +use Chill\MainBundle\Form\UserJobType; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -183,6 +187,8 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, 'JSONB_EXISTS_IN_ARRAY' => JsonbExistsInArray::class, 'SIMILARITY' => Similarity::class, 'OVERLAPSI' => OverlapsI::class, + 'STRICT_WORD_SIMILARITY_OPS' => StrictWordSimilarityOPS::class, + 'ST_CONTAINS' => STContains::class, ], ], 'hydrators' => [ @@ -264,6 +270,27 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, protected function prependCruds(ContainerBuilder $container) { $container->prependExtensionConfig('chill_main', [ + 'cruds' => [ + [ + 'class' => UserJob::class, + 'name' => 'admin_user_job', + 'base_path' => '/admin/main/user-job', + 'base_role' => 'ROLE_ADMIN', + 'form_class' => UserJobType::class, + 'actions' => [ + 'index' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillMain/UserJob/index.html.twig', + ], + 'new' => [ + 'role' => 'ROLE_ADMIN' + ], + 'edit' => [ + 'role' => 'ROLE_ADMIN' + ] + ], + ], + ], 'apis' => [ [ 'class' => \Chill\MainBundle\Entity\Address::class, @@ -371,6 +398,26 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, ], ] ], + [ + 'class' => \Chill\MainBundle\Entity\Scope::class, + 'name' => 'scope', + 'base_path' => '/api/1.0/main/scope', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true + ], + ], + '_entity' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ] + ], + ] + ], ] ]); } diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/STContains.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/STContains.php new file mode 100644 index 000000000..5235d51bb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/STContains.php @@ -0,0 +1,52 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Doctrine\DQL; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; + +/** + * Geometry function 'ST_CONTAINS', added by postgis + */ +class STContains extends FunctionNode +{ + private $firstPart; + + private $secondPart; + + public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker) + { + return 'ST_CONTAINS('.$this->firstPart->dispatch($sqlWalker). + ', ' . $this->secondPart->dispatch($sqlWalker) .")"; + } + + public function parse(\Doctrine\ORM\Query\Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->firstPart = $parser->StringPrimary(); + + $parser->match(Lexer::T_COMMA); + + $this->secondPart = $parser->StringPrimary(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/Similarity.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/Similarity.php index 988e55f5f..1875f978c 100644 --- a/src/Bundle/ChillMainBundle/Doctrine/DQL/Similarity.php +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/Similarity.php @@ -21,20 +21,15 @@ namespace Chill\MainBundle\Doctrine\DQL; use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\Lexer; -/** - * - * - * - */ class Similarity extends FunctionNode { private $firstPart; - + private $secondPart; - + public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker) { - return 'SIMILARITY('.$this->firstPart->dispatch($sqlWalker). + return 'SIMILARITY('.$this->firstPart->dispatch($sqlWalker). ', ' . $this->secondPart->dispatch($sqlWalker) .")"; } @@ -42,13 +37,13 @@ class Similarity extends FunctionNode { $parser->match(Lexer::T_IDENTIFIER); $parser->match(Lexer::T_OPEN_PARENTHESIS); - + $this->firstPart = $parser->StringPrimary(); - + $parser->match(Lexer::T_COMMA); - + $this->secondPart = $parser->StringPrimary(); - + $parser->match(Lexer::T_CLOSE_PARENTHESIS); } } diff --git a/src/Bundle/ChillMainBundle/Doctrine/DQL/StrictWordSimilarityOPS.php b/src/Bundle/ChillMainBundle/Doctrine/DQL/StrictWordSimilarityOPS.php new file mode 100644 index 000000000..22dcda879 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Doctrine/DQL/StrictWordSimilarityOPS.php @@ -0,0 +1,34 @@ +firstPart->dispatch($sqlWalker). + ' <<% ' . $this->secondPart->dispatch($sqlWalker); + } + + public function parse(\Doctrine\ORM\Query\Parser $parser) + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->firstPart = $parser->StringPrimary(); + + $parser->match(Lexer::T_COMMA); + + $this->secondPart = $parser->StringPrimary(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/src/Bundle/ChillMainBundle/Doctrine/Type/PointType.php b/src/Bundle/ChillMainBundle/Doctrine/Type/PointType.php index 086b566da..0a0c160cd 100644 --- a/src/Bundle/ChillMainBundle/Doctrine/Type/PointType.php +++ b/src/Bundle/ChillMainBundle/Doctrine/Type/PointType.php @@ -21,7 +21,7 @@ class PointType extends Type { * * @param array $fieldDeclaration * @param AbstractPlatform $platform - * @return type + * @return string */ public function getSqlDeclaration(array $fieldDeclaration, AbstractPlatform $platform) { @@ -32,7 +32,7 @@ class PointType extends Type { * * @param type $value * @param AbstractPlatform $platform - * @return Point + * @return ?Point */ public function convertToPHPValue($value, AbstractPlatform $platform) { diff --git a/src/Bundle/ChillMainBundle/Entity/Address.php b/src/Bundle/ChillMainBundle/Entity/Address.php index f3973ff0f..22e4848bf 100644 --- a/src/Bundle/ChillMainBundle/Entity/Address.php +++ b/src/Bundle/ChillMainBundle/Entity/Address.php @@ -383,6 +383,16 @@ class Address ; } + public static function createFromAddressReference(AddressReference $original): Address + { + return (new Address()) + ->setPoint($original->getPoint()) + ->setPostcode($original->getPostcode()) + ->setStreet($original->getStreet()) + ->setStreetNumber($original->getStreetNumber()) + ; + } + public function getStreet(): ?string { return $this->street; diff --git a/src/Bundle/ChillMainBundle/Entity/HasCentersInterface.php b/src/Bundle/ChillMainBundle/Entity/HasCentersInterface.php new file mode 100644 index 000000000..921cbba41 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/HasCentersInterface.php @@ -0,0 +1,8 @@ + - * + * * 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 . */ @@ -46,17 +46,17 @@ class Scope * @Groups({"read"}) */ private $id; - + /** * translatable names - * + * * @var array * * @ORM\Column(type="json_array") * @Groups({"read"}) */ private $name = []; - + /** * @var Collection * @@ -66,8 +66,8 @@ class Scope * @ORM\Cache(usage="NONSTRICT_READ_WRITE") */ private $roleScopes; - - + + /** * Scope constructor. */ @@ -75,7 +75,7 @@ class Scope { $this->roleScopes = new ArrayCollection(); } - + /** * @return int */ @@ -91,7 +91,7 @@ class Scope { return $this->name; } - + /** * @param $name * @return $this @@ -101,7 +101,7 @@ class Scope $this->name = $name; return $this; } - + /** * @return Collection */ @@ -109,7 +109,7 @@ class Scope { return $this->roleScopes; } - + /** * @param RoleScope $roleScope */ diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 76bb7b54d..41d6db40c 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -5,6 +5,7 @@ namespace Chill\MainBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ArrayCollection; +use Chill\MainBundle\Entity\UserJob; use Symfony\Component\Security\Core\User\AdvancedUserInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; @@ -20,7 +21,7 @@ use Symfony\Component\Serializer\Annotation\DiscriminatorMap; * }) */ class User implements AdvancedUserInterface { - + /** * @var integer * @@ -36,24 +37,30 @@ class User implements AdvancedUserInterface { * @ORM\Column(type="string", length=80) */ private $username; - + /** * @var string * * @ORM\Column( * type="string", * length=80, - * unique=true) + * unique=true, + * nullable=true) */ private $usernameCanonical; - + + /** + * @ORM\Column(type="string", length=200) + */ + private string $label = ''; + /** * @var string * * @ORM\Column(type="string", length=150, nullable=true) */ private $email; - + /** * @var string * @@ -64,14 +71,14 @@ class User implements AdvancedUserInterface { * unique=true) */ private $emailCanonical; - + /** * @var string * * @ORM\Column(type="string", length=255) */ private $password; - + /** * @var string * @internal must be set to null if we use bcrypt @@ -79,7 +86,7 @@ class User implements AdvancedUserInterface { * @ORM\Column(type="string", length=255, nullable=true) */ private $salt = null; - + /** * @var boolean * @@ -87,14 +94,14 @@ class User implements AdvancedUserInterface { * sf4 check: in yml was false by default !? */ private $locked = true; - + /** * @var boolean * * @ORM\Column(type="boolean") */ private $enabled = true; - + /** * @var Collection * @@ -112,7 +119,25 @@ class User implements AdvancedUserInterface { * @ORM\Column(type="json_array", nullable=true) */ private $attributes; - + + /** + * @var Center|null + * @ORM\ManyToOne(targetEntity=Center::class) + */ + private ?Center $mainCenter = null; + + /** + * @var Scope|null + * @ORM\ManyToOne(targetEntity=Scope::class) + */ + private ?Scope $mainScope = null; + + /** + * @var UserJob|null + * @ORM\ManyToOne(targetEntity=UserJob::class) + */ + private ?UserJob $userJob = null; + /** * User constructor. */ @@ -120,13 +145,13 @@ class User implements AdvancedUserInterface { { $this->groupCenters = new ArrayCollection(); } - + /** * @return string */ public function __toString() { - return $this->getUsername(); + return $this->getLabel(); } /** @@ -148,10 +173,14 @@ class User implements AdvancedUserInterface { public function setUsername($name) { $this->username = $name; - + + if (empty($this->getLabel())) { + $this->setLabel($name); + } + return $this; } - + /** * @return string */ @@ -159,11 +188,11 @@ class User implements AdvancedUserInterface { { return $this->username; } - + /** */ public function eraseCredentials() {} - + /** * @return array */ @@ -171,7 +200,7 @@ class User implements AdvancedUserInterface { { return array('ROLE_USER'); } - + /** * @return null|string */ @@ -179,7 +208,7 @@ class User implements AdvancedUserInterface { { return $this->salt; } - + /** * @param $usernameCanonical * @return $this @@ -187,10 +216,10 @@ class User implements AdvancedUserInterface { public function setUsernameCanonical($usernameCanonical) { $this->usernameCanonical = $usernameCanonical; - + return $this; } - + /** * @return string */ @@ -198,7 +227,7 @@ class User implements AdvancedUserInterface { { return $this->usernameCanonical; } - + /** * @param $email * @return $this @@ -206,10 +235,10 @@ class User implements AdvancedUserInterface { public function setEmail($email) { $this->email = $email; - + return $this; } - + /** * @return string */ @@ -217,7 +246,7 @@ class User implements AdvancedUserInterface { { return $this->email; } - + /** * @param $emailCanonical * @return $this @@ -225,10 +254,10 @@ class User implements AdvancedUserInterface { public function setEmailCanonical($emailCanonical) { $this->emailCanonical = $emailCanonical; - + return $this; } - + /** * @return string */ @@ -236,7 +265,7 @@ class User implements AdvancedUserInterface { { return $this->emailCanonical; } - + /** * @param $password * @return $this @@ -244,7 +273,7 @@ class User implements AdvancedUserInterface { function setPassword($password) { $this->password = $password; - + return $this; } @@ -255,7 +284,7 @@ class User implements AdvancedUserInterface { { return $this->password; } - + /** * @param $salt * @return $this @@ -265,7 +294,7 @@ class User implements AdvancedUserInterface { $this->salt = $salt; return $this; } - + /** * @return bool */ @@ -273,7 +302,7 @@ class User implements AdvancedUserInterface { { return true; } - + /** * @return bool */ @@ -281,7 +310,7 @@ class User implements AdvancedUserInterface { { return $this->locked; } - + /** * @return bool */ @@ -289,7 +318,7 @@ class User implements AdvancedUserInterface { { return true; } - + /** * @return bool */ @@ -297,17 +326,17 @@ class User implements AdvancedUserInterface { { return $this->enabled; } - + /** * @param bool $enabled */ public function setEnabled($enabled) { $this->enabled = $enabled; - + return $this; } - + /** * @return GroupCenter */ @@ -315,7 +344,7 @@ class User implements AdvancedUserInterface { { return $this->groupCenters; } - + /** * @param \Chill\MainBundle\Entity\GroupCenter $groupCenter * @return \Chill\MainBundle\Entity\User @@ -325,7 +354,7 @@ class User implements AdvancedUserInterface { $this->groupCenters->add($groupCenter); return $this; } - + /** * @param \Chill\MainBundle\Entity\GroupCenter $groupCenter * @throws \RuntimeException if the groupCenter is not in the collection @@ -337,9 +366,9 @@ class User implements AdvancedUserInterface { . "it seems not to be associated with the user. Aborting.")); } } - + /** - * This function check that groupCenter are present only once. The validator + * This function check that groupCenter are present only once. The validator * use this function to avoid a user to be associated to the same groupCenter * more than once. */ @@ -350,7 +379,7 @@ class User implements AdvancedUserInterface { if (in_array($groupCenter->getId(), $groupCentersIds)) { $context->buildViolation("The user has already those permissions") ->addViolation(); - + } else { $groupCentersIds[] = $groupCenter->getId(); } @@ -384,4 +413,76 @@ class User implements AdvancedUserInterface { return $this->attributes; } + + /** + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * @param string $label + * @return User + */ + public function setLabel(string $label): User + { + $this->label = $label; + return $this; + } + + /** + * @return Center|null + */ + public function getMainCenter(): ?Center + { + return $this->mainCenter; + } + + /** + * @param Center|null $mainCenter + * @return User + */ + public function setMainCenter(?Center $mainCenter): User + { + $this->mainCenter = $mainCenter; + return $this; + } + + /** + * @return Scope|null + */ + public function getMainScope(): ?Scope + { + return $this->mainScope; + } + + /** + * @param Scope|null $mainScope + * @return User + */ + public function setMainScope(?Scope $mainScope): User + { + $this->mainScope = $mainScope; + return $this; + } + + /** + * @return UserJob|null + */ + public function getUserJob(): ?UserJob + { + return $this->userJob; + } + + /** + * @param UserJob|null $userJob + * @return User + */ + public function setUserJob(?UserJob $userJob): User + { + $this->userJob = $userJob; + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/UserJob.php b/src/Bundle/ChillMainBundle/Entity/UserJob.php new file mode 100644 index 000000000..9d1ca9157 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/UserJob.php @@ -0,0 +1,76 @@ +id; + } + + /** + * @return array|string[] + */ + public function getLabel(): array + { + return $this->label; + } + + /** + * @param array|string[] $label + * @return UserJob + */ + public function setLabel(array $label): UserJob + { + $this->label = $label; + return $this; + } + + /** + * @return bool + */ + public function isActive(): bool + { + return $this->active; + } + + /** + * @param bool $active + * @return UserJob + */ + public function setActive(bool $active): UserJob + { + $this->active = $active; + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Event/CustomizeFormEvent.php b/src/Bundle/ChillMainBundle/Form/Event/CustomizeFormEvent.php new file mode 100644 index 000000000..43fab6f62 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Event/CustomizeFormEvent.php @@ -0,0 +1,36 @@ +type = $type; + $this->builder = $builder; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return FormBuilderInterface + */ + public function getBuilder(): FormBuilderInterface + { + return $this->builder; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/CenterType.php b/src/Bundle/ChillMainBundle/Form/Type/CenterType.php index ba039c938..0366e8ccf 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/CenterType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/CenterType.php @@ -95,9 +95,12 @@ class CenterType extends AbstractType public function configureOptions(OptionsResolver $resolver) { if (count($this->reachableCenters) > 1) { - $resolver->setDefault('class', Center::class); - $resolver->setDefault('choices', $this->reachableCenters); + $resolver->setDefault('class', Center::class) + ->setDefault('choices', $this->reachableCenters) + ->setDefault('placeholder', 'Pick a center') + ; } + } /** diff --git a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php index 832db628f..ebf474657 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php @@ -146,14 +146,7 @@ class ScopePickerType extends AbstractType ->setParameter('center', $center->getId()) // role constraints ->andWhere($qb->expr()->in('rs.role', ':roles')) - ->setParameter( - 'roles', \array_map( - function (Role $role) { - return $role->getRole(); - }, - $roles - ) - ) + ->setParameter('roles', $roles) // user contraint ->andWhere(':user MEMBER OF gc.users') ->setParameter('user', $this->tokenStorage->getToken()->getUser()); diff --git a/src/Bundle/ChillMainBundle/Form/UserJobType.php b/src/Bundle/ChillMainBundle/Form/UserJobType.php new file mode 100644 index 000000000..eec718261 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/UserJobType.php @@ -0,0 +1,27 @@ +add('label', TranslatableStringFormType::class, [ + 'label' => 'Label', + 'required' => true + ]) + ->add('active', ChoiceType::class, [ + 'choices' => [ + 'Active' => true, + 'Inactive' => false + ] + ]) + ; + } + +} diff --git a/src/Bundle/ChillMainBundle/Form/UserType.php b/src/Bundle/ChillMainBundle/Form/UserType.php index 5196ee6c0..09e2d1391 100644 --- a/src/Bundle/ChillMainBundle/Form/UserType.php +++ b/src/Bundle/ChillMainBundle/Form/UserType.php @@ -2,7 +2,15 @@ namespace Chill\MainBundle\Form; +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\Scope; +use Chill\MainBundle\Entity\UserJob; +use Chill\MainBundle\Templating\TranslatableStringHelper; +use Doctrine\ORM\EntityRepository; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -16,6 +24,16 @@ use Chill\MainBundle\Form\UserPasswordType; class UserType extends AbstractType { + private TranslatableStringHelper $translatableStringHelper; + + /** + * @param TranslatableStringHelper $translatableStringHelper + */ + public function __construct(TranslatableStringHelper $translatableStringHelper) + { + $this->translatableStringHelper = $translatableStringHelper; + } + /** * @param FormBuilderInterface $builder * @param array $options @@ -24,7 +42,40 @@ class UserType extends AbstractType { $builder ->add('username') - ->add('email') + ->add('email', EmailType::class, [ + 'required' => true + ]) + ->add('label', TextType::class) + ->add('mainCenter', EntityType::class, [ + 'label' => 'main center', + 'required' => false, + 'placeholder' => 'choose a main center', + 'class' => Center::class, + 'query_builder' => function (EntityRepository $er) { + $qb = $er->createQueryBuilder('c'); + $qb->addOrderBy('c.name'); + + return $qb; + } + ]) + ->add('mainScope', EntityType::class, [ + 'label' => 'Choose a main scope', + 'required' => false, + 'placeholder' => 'choose a main scope', + 'class' => Scope::class, + 'choice_label' => function (Scope $c) { + return $this->translatableStringHelper->localize($c->getName()); + }, + ]) + ->add('userJob', EntityType::class, [ + 'label' => 'Choose a job', + 'required' => false, + 'placeholder' => 'choose a job', + 'class' => UserJob::class, + 'choice_label' => function (UserJob $c) { + return $this->translatableStringHelper->localize($c->getLabel()); + }, + ]) ; if ($options['is_creation']) { $builder->add('plainPassword', RepeatedType::class, array( diff --git a/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php b/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php index f040446b3..cf488f411 100644 --- a/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php @@ -36,6 +36,14 @@ final class AddressReferenceRepository implements ObjectRepository return $this->repository->findAll(); } + public function countAll(): int + { + $qb = $this->repository->createQueryBuilder('ar'); + $qb->select('count(ar.id)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + /** * @return AddressReference[] */ diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js new file mode 100644 index 000000000..a8df4ed88 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/scope.js @@ -0,0 +1,17 @@ +const fetchScopes = () => { + return window.fetch('/api/1.0/main/scope.json').then(response => { + if (response.ok) { + return response.json(); + } + }).then(data => { + console.log(data); + return new Promise((resolve, reject) => { + console.log(data); + resolve(data.results); + }); + }); +}; + +export { + fetchScopes +}; diff --git a/src/Bundle/ChillMainBundle/Resources/views/CRUD/Admin/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/CRUD/Admin/index.html.twig new file mode 100644 index 000000000..1dc0acc81 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/CRUD/Admin/index.html.twig @@ -0,0 +1,8 @@ +{% extends '@ChillMain/Admin/layout.html.twig' %} + +{% block title %}{{ ('crud.' ~ crud_name ~ '.index.title')|trans({'%crud_name%': crud_name}) }}{% endblock %} + +{% block content %} + {% embed '@ChillMain/CRUD/_index.html.twig' %} + {% endembed %} +{% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/UserJob/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/UserJob/index.html.twig new file mode 100644 index 000000000..8f037f150 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/UserJob/index.html.twig @@ -0,0 +1,26 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block content %} + {% embed '@ChillMain/CRUD/_index.html.twig' %} + {% block table_entities_thead_tr %} + id + label +   + {% endblock %} + {% block table_entities_tbody %} + {% for entity in entities %} + + {{ entity.id }} + {{ entity.label|localize_translatable_string }} + +
    +
  • + +
  • +
+ + + {% endfor %} + {% endblock %} + {% endembed %} +{% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Search/SearchInterface.php b/src/Bundle/ChillMainBundle/Search/SearchInterface.php index 708835b47..958ce9ddd 100644 --- a/src/Bundle/ChillMainBundle/Search/SearchInterface.php +++ b/src/Bundle/ChillMainBundle/Search/SearchInterface.php @@ -23,45 +23,44 @@ namespace Chill\MainBundle\Search; /** * This interface must be implemented on services which provide search results. - * + * * @todo : write doc and add a link to documentation - * + * * @author Julien Fastré * */ interface SearchInterface { - const SEARCH_PREVIEW_OPTION = '_search_preview'; - + /** * Request parameters contained inside the `add_q` parameters */ const REQUEST_QUERY_PARAMETERS = '_search_parameters'; - + /** * Supplementary parameters to the query string */ const REQUEST_QUERY_KEY_ADD_PARAMETERS = 'add_q'; - /** + /** * return the result in a html string. The string will be inclued (as raw) * into a global view. - * + * * The global view may be : * {% for result as resultsFromDifferentSearchInterface %} * {{ result|raw }} * {% endfor %} - * + * * **available options** : - * - SEARCH_PREVIEW_OPTION (boolean) : if renderResult should return a "preview" of + * - SEARCH_PREVIEW_OPTION (boolean) : if renderResult should return a "preview" of * the results. In this case, a subset of results should be returned, and, * if the query return more results, a button "see all results" should be * displayed at the end of the list. - * - * **Interaction between `start` and `limit` and pagination : you should - * take only the given parameters into account; the results from pagination - * should be ignored. (Most of the time, it should be the same). + * + * **Interaction between `start` and `limit` and pagination : you should + * take only the given parameters into account; the results from pagination + * should be ignored. (Most of the time, it should be the same). * * @param array $terms the string to search * @param int $start the first result (for pagination) @@ -72,10 +71,10 @@ interface SearchInterface */ public function renderResult(array $terms, $start=0, $limit=50, array $options = array(), $format = 'html'); - /** + /** * we may desactive the search interface by default. in this case, - * the search will be launch and rendered only with "advanced search" - * + * the search will be launch and rendered only with "advanced search" + * * this may be activated/desactived from bundle definition in config.yml * * @return bool @@ -84,18 +83,18 @@ interface SearchInterface /** * the order in which the results will appears in the global view - * + * * (this may be eventually defined in config.yml) - * - * @return int + * + * @return int */ public function getOrder(); - + /** * indicate if the implementation supports the given domain - * + * * @return boolean */ public function supports($domain, $format); - + } diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/AbstractChillVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/AbstractChillVoter.php index 842fc5ecc..9131a6501 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/AbstractChillVoter.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/AbstractChillVoter.php @@ -23,8 +23,8 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; /** - * Voter for Chill software. - * + * Voter for Chill software. + * * This abstract Voter provide generic methods to handle object specific to Chill * * @@ -36,20 +36,20 @@ abstract class AbstractChillVoter extends Voter implements ChillVoterInterface { @trigger_error('This voter should implements the new `supports` ' . 'methods introduced by Symfony 3.0, and do not rely on ' - . 'getSupportedAttributes and getSupportedClasses methods.', + . 'getSupportedAttributes and getSupportedClasses methods.', E_USER_DEPRECATED); return \in_array($attribute, $this->getSupportedAttributes($attribute)) && \in_array(\get_class($subject), $this->getSupportedClasses()); } - + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) { @trigger_error('This voter should implements the new `voteOnAttribute` ' . 'methods introduced by Symfony 3.0, and do not rely on ' . 'isGranted method', E_USER_DEPRECATED); - + return $this->isGranted($attribute, $subject, $token->getUser()); } - + } diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php index 697158bf8..2b223a0fe 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php @@ -23,6 +23,11 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\HasCenterInterface; use Chill\MainBundle\Entity\HasScopeInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; +use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; +use Chill\MainBundle\Security\Resolver\ScopeResolverInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; use Symfony\Component\Security\Core\Role\Role; use Chill\MainBundle\Entity\Scope; @@ -32,87 +37,126 @@ use Chill\MainBundle\Entity\GroupCenter; use Chill\MainBundle\Entity\RoleScope; /** - * Helper for authorizations. - * + * Helper for authorizations. + * * Provides methods for user and entities information. * * @author Julien Fastré */ class AuthorizationHelper { + protected RoleHierarchyInterface $roleHierarchy; + /** - * - * @var RoleHierarchyInterface - */ - protected $roleHierarchy; - - /** - * The role in a hierarchy, given by the parameter + * The role in a hierarchy, given by the parameter * `security.role_hierarchy.roles` from the container. * * @var string[] */ - protected $hierarchy; - - /** - * - * @var EntityManagerInterface - */ - protected $em; - + protected array $hierarchy; + + protected EntityManagerInterface $em; + + protected CenterResolverDispatcher $centerResolverDispatcher; + + protected ScopeResolverDispatcher $scopeResolverDispatcher; + + protected LoggerInterface $logger; + public function __construct( RoleHierarchyInterface $roleHierarchy, - $hierarchy, - EntityManagerInterface $em + ParameterBagInterface $parameterBag, + EntityManagerInterface $em, + CenterResolverDispatcher $centerResolverDispatcher, + LoggerInterface $logger, + ScopeResolverDispatcher $scopeResolverDispatcher ) { $this->roleHierarchy = $roleHierarchy; - $this->hierarchy = $hierarchy; + $this->hierarchy = $parameterBag->get('security.role_hierarchy.roles'); $this->em = $em; + $this->centerResolverDispatcher = $centerResolverDispatcher; + $this->logger = $logger; + $this->scopeResolverDispatcher = $scopeResolverDispatcher; } - + /** * Determines if a user is active on this center - * + * + * If + * * @param User $user - * @param Center $center + * @param Center|Center[] $center May be an array of center * @return bool */ - public function userCanReachCenter(User $user, Center $center) + public function userCanReachCenter(User $user, $center) { - foreach ($user->getGroupCenters() as $groupCenter) { - if ($center->getId() === $groupCenter->getCenter()->getId()) { - - return true; + if ($center instanceof \Traversable) { + foreach ($center as $c) { + if ($c->userCanReachCenter($user, $c)) { + return true; + } } + return false; + } elseif ($center instanceof Center) { + foreach ($user->getGroupCenters() as $groupCenter) { + if ($center->getId() === $groupCenter->getCenter()->getId()) { + return true; + } + } + + return false; } - - return false; + + throw new \UnexpectedValueException(sprintf("The entity given is not an ". + "instance of %s, %s given", Center::class, get_class($center))); } - + /** - * + * * Determines if the user has access to the given entity. - * + * * if the entity implements Chill\MainBundle\Entity\HasScopeInterface, * the scope is taken into account. - * + * * @param User $user - * @param HasCenterInterface $entity the entity may also implement HasScopeInterface + * @param mixed $entity the entity may also implement HasScopeInterface * @param string|Role $attribute * @return boolean true if the user has access */ - public function userHasAccess(User $user, HasCenterInterface $entity, $attribute) + public function userHasAccess(User $user, $entity, $attribute) + { + $center = $this->centerResolverDispatcher->resolveCenter($entity); + + if (is_iterable($center)) { + foreach ($center as $c) { + if ($this->userHasAccessForCenter($user, $c, $entity, $attribute)) { + return true; + } + } + + return false; + } elseif ($center instanceof Center) { + return $this->userHasAccessForCenter($user, $center, $entity, $attribute); + } elseif (NULL === $center) { + return false; + } else { + throw new \UnexpectedValueException("could not resolver a center"); + } + } + + private function userHasAccessForCenter(User $user, Center $center, $entity, $attribute): bool { - - $center = $entity->getCenter(); - if (!$this->userCanReachCenter($user, $center)) { + $this->logger->debug("user cannot reach center of entity", [ + 'center_name' => $center->getName(), + 'user' => $user->getUsername() + ]); return false; } - + foreach ($user->getGroupCenters() as $groupCenter){ //filter on center - if ($groupCenter->getCenter()->getId() === $entity->getCenter()->getId()) { + if ($groupCenter->getCenter() === $center) { $permissionGroup = $groupCenter->getPermissionsGroup(); //iterate on roleScopes foreach($permissionGroup->getRoleScopes() as $roleScope) { @@ -120,31 +164,42 @@ class AuthorizationHelper if ($this->isRoleReached($attribute, $roleScope->getRole())) { //if yes, we have a right on something... // perform check on scope if necessary - if ($entity instanceof HasScopeInterface) { - $scope = $entity->getScope(); - if ($scope === NULL) { - return true; - } - if ($scope->getId() === $roleScope - ->getScope()->getId()) { - return true; - } + if ($this->scopeResolverDispatcher->isConcerned($entity)) { + $scope = $this->scopeResolverDispatcher->resolveScope($entity); + + if (NULL === $scope) { + return true; + } elseif (is_iterable($scope)) { + foreach ($scope as $s) { + if ($s === $roleScope->getScope()) { + return true; + } + } + } else { + if ($scope === $roleScope->getScope()) { + return true; + } + } } else { return true; } } } - } } - + + $this->logger->debug("user can reach center entity, but not role", [ + 'username' => $user->getUsername(), + 'center' => $center->getName() + ]); + return false; } - + /** * Get reachable Centers for the given user, role, * and optionnaly Scope - * + * * @param User $user * @param string|Role $role * @param null|Scope $scope @@ -156,7 +211,7 @@ class AuthorizationHelper $role = $role->getRole(); } $centers = array(); - + foreach ($user->getGroupCenters() as $groupCenter){ $permissionGroup = $groupCenter->getPermissionsGroup(); //iterate on roleScopes @@ -170,13 +225,13 @@ class AuthorizationHelper if ($scope->getId() == $roleScope->getScope()->getId()){ $centers[] = $groupCenter->getCenter(); break 1; - } + } } } } - + } - + return $centers; } @@ -203,18 +258,18 @@ class AuthorizationHelper return $results; } - + /** * Return all reachable scope for a given user, center and role - * + * * @deprecated Use getReachableCircles * * @param User $user - * @param Role $role - * @param Center $center + * @param string role + * @param Center|Center[] $center * @return Scope[] */ - public function getReachableScopes(User $user, $role, Center $center) + public function getReachableScopes(User $user, $role, $center) { if ($role instanceof Role) { $role = $role->getRole(); @@ -222,22 +277,31 @@ class AuthorizationHelper return $this->getReachableCircles($user, $role, $center); } - + /** * Return all reachable circle for a given user, center and role - * + * * @param User $user * @param string|Role $role - * @param Center $center + * @param Center|Center[] $center * @return Scope[] */ - public function getReachableCircles(User $user, $role, Center $center) + public function getReachableCircles(User $user, $role, $center) { + $scopes = []; + + if (is_iterable($center)) { + foreach ($center as $c) { + $scopes = \array_merge($scopes, $this->getReachableCircles($user, $role, $c)); + } + + return $scopes; + } + if ($role instanceof Role) { $role = $role->getRole(); } - $scopes = array(); - + foreach ($user->getGroupCenters() as $groupCenter){ if ($center->getId() === $groupCenter->getCenter()->getId()) { //iterate on permissionGroup @@ -251,23 +315,19 @@ class AuthorizationHelper } } } - + return $scopes; } - + /** - * - * @param Role $role - * @param Center $center - * @param Scope $circle - * @return Users + * + * @return User[] */ - public function findUsersReaching(Role $role, Center $center, Scope $circle = null) + public function findUsersReaching(string $role, Center $center, Scope $circle = null): array { $parents = $this->getParentRoles($role); $parents[] = $role; - $parentRolesString = \array_map(function(Role $r) { return $r->getRole(); }, $parents); - + $qb = $this->em->createQueryBuilder(); $qb ->select('u') @@ -276,23 +336,23 @@ class AuthorizationHelper ->join('gc.permissionsGroup', 'pg') ->join('pg.roleScopes', 'rs') ->where('gc.center = :center') - ->andWhere($qb->expr()->in('rs.role', $parentRolesString)) + ->andWhere($qb->expr()->in('rs.role', $parents)) ; - + $qb->setParameter('center', $center); - + if ($circle !== null) { $qb->andWhere('rs.scope = :circle') ->setParameter('circle', $circle) ; } - + return $qb->getQuery()->getResult(); } - + /** * Test if a parent role may give access to a given child role - * + * * @param Role $childRole The role we want to test if he is reachable * @param Role $parentRole The role which should give access to $childRole * @return boolean true if the child role is granted by parent role @@ -301,36 +361,31 @@ class AuthorizationHelper { $reachableRoles = $this->roleHierarchy ->getReachableRoleNames([$parentRole]); - + return in_array($childRole, $reachableRoles); } - + /** - * Return all the role which give access to the given role. Only the role + * Return all the role which give access to the given role. Only the role * which are registered into Chill are taken into account. - * + * * @param Role $role - * @return Role[] the role which give access to the given $role + * @return string[] the role which give access to the given $role */ - public function getParentRoles(Role $role) + public function getParentRoles($role): array { $parentRoles = []; // transform the roles from role hierarchy from string to Role - $roles = \array_map( - function($string) { - return new Role($string); - }, - \array_keys($this->hierarchy) - ); - + $roles = \array_keys($this->hierarchy); + foreach ($roles as $r) { - $childRoles = $this->roleHierarchy->getReachableRoleNames([$r->getRole()]); - + $childRoles = $this->roleHierarchy->getReachableRoleNames([$r]); + if (\in_array($role, $childRoles)) { $parentRoles[] = $r; } } - + return $parentRoles; } } diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelper.php b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelper.php new file mode 100644 index 000000000..ef1d319ac --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelper.php @@ -0,0 +1,62 @@ +authorizationHelper = $authorizationHelper; + $this->centerResolverDispatcher = $centerResolverDispatcher; + $this->configuration = $configuration; + } + + public function supports($attribute, $subject): bool + { + foreach ($this->configuration as list($attributes, $subj)) { + if ($subj === null) { + if ($subject === null && \in_array($attribute, $attributes)) { + return true; + } + } elseif ($subject instanceof $subj) { + return \in_array($attribute, $attributes); + } + } + + return false; + } + + public function voteOnAttribute($attribute, $subject, $token): bool + { + if (!$token->getUser() instanceof User) { + return false; + } + + if (NULL === $subject) { + return 0 < count($this->authorizationHelper->getReachableCenters($token->getUser(), $attribute, null)); + } + + return $this->authorizationHelper->userHasAccess( + $token->getUser(), + $subject, + $attribute + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperFactory.php b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperFactory.php new file mode 100644 index 000000000..9349eaed2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperFactory.php @@ -0,0 +1,27 @@ +authorizationHelper = $authorizationHelper; + $this->centerResolverDispatcher = $centerResolverDispatcher; + } + + public function generate($context): VoterGeneratorInterface + { + return new DefaultVoterHelperGenerator( + $this->authorizationHelper, + $this->centerResolverDispatcher + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperGenerator.php b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperGenerator.php new file mode 100644 index 000000000..c8300e45a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/DefaultVoterHelperGenerator.php @@ -0,0 +1,36 @@ +authorizationHelper = $authorizationHelper; + $this->centerResolverDispatcher = $centerResolverDispatcher; + } + + public function addCheckFor(?string $subject, array $attributes): self + { + $this->configuration[] = [$attributes, $subject]; + + return $this; + } + + public function build(): VoterHelperInterface + { + return new DefaultVoterHelper( + $this->authorizationHelper, + $this->centerResolverDispatcher, + $this->configuration + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php b/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php new file mode 100644 index 000000000..70e517536 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php @@ -0,0 +1,15 @@ +resolvers = $resolvers; + } + + /** + * @param mixed $entity + * @param array|null $options + * @return null|Center|Center[] + */ + public function resolveCenter($entity, ?array $options = []) + { + foreach($this->resolvers as $priority => $resolver) { + if ($resolver->supports($entity, $options)) { + return $resolver->resolveCenter($entity, $options); + } + } + + return null; + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/CenterResolverInterface.php b/src/Bundle/ChillMainBundle/Security/Resolver/CenterResolverInterface.php new file mode 100644 index 000000000..db49874b7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Resolver/CenterResolverInterface.php @@ -0,0 +1,19 @@ +getCenter(); + } elseif ($entity instanceof HasCentersInterface) { + return $entity->getCenters(); + } else { + throw new \UnexpectedValueException("should be an instanceof"); + } + } + + public static function getDefaultPriority(): int + { + return -256; + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/DefaultScopeResolver.php b/src/Bundle/ChillMainBundle/Security/Resolver/DefaultScopeResolver.php new file mode 100644 index 000000000..caf052764 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Resolver/DefaultScopeResolver.php @@ -0,0 +1,42 @@ +getScope(); + } elseif ($entity instanceof HasScopesInterface) { + return $entity->getScopes(); + } else { + throw new \UnexpectedValueException("should be an instanceof %s or %s", + HasScopesInterface::class, HasScopeInterface::class); + } + } + + public function isConcerned($entity, ?array $options = []): bool + { + return $entity instanceof HasScopeInterface || $entity instanceof HasScopesInterface; + } + + public static function getDefaultPriority(): int + { + return -256; + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/ResolverTwigExtension.php b/src/Bundle/ChillMainBundle/Security/Resolver/ResolverTwigExtension.php new file mode 100644 index 000000000..21ece21a3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Resolver/ResolverTwigExtension.php @@ -0,0 +1,36 @@ +centerResolverDispatcher = $centerResolverDispatcher; + } + + public function getFilters() + { + return [ + new TwigFilter('chill_resolve_center', [$this, 'resolveCenter']) + ]; + } + + /** + * @param mixed $entity + * @param array|null $options + * @return Center|Center[]|null + */ + public function resolveCenter($entity, ?array $options = []) + { + return $this->centerResolverDispatcher->resolveCenter($entity, $options); + } + +} diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverDispatcher.php b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverDispatcher.php new file mode 100644 index 000000000..cc3f11560 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverDispatcher.php @@ -0,0 +1,44 @@ +resolvers = $resolvers; + } + + /** + * @param $entity + * @return Scope|Scope[]|iterable + */ + public function resolveScope($entity, ?array $options = []) + { + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($entity, $options)) { + return $resolver->resolveScope($entity, $options); + } + } + + return null; + } + + public function isConcerned($entity, ?array $options = []): bool + { + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($entity, $options)) { + return $resolver->isConcerned($entity, $options); + } + } + + return false; + } +} diff --git a/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php new file mode 100644 index 000000000..04e949a59 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Resolver/ScopeResolverInterface.php @@ -0,0 +1,35 @@ +client = static::createClient(array(), array( 'PHP_AUTH_USER' => 'admin', 'PHP_AUTH_PW' => 'password', @@ -23,62 +23,64 @@ class UserControllerTest extends WebTestCase { // get the list $crawler = $this->client->request('GET', '/fr/admin/user/'); - $this->assertEquals(200, $this->client->getResponse()->getStatusCode(), + $this->assertEquals(200, $this->client->getResponse()->getStatusCode(), "Unexpected HTTP status code for GET /admin/user/"); - + $link = $crawler->selectLink('Ajouter un nouvel utilisateur')->link(); $this->assertInstanceOf('Symfony\Component\DomCrawler\Link', $link); $this->assertRegExp('|/fr/admin/user/new$|', $link->getUri()); } - + public function testNew() { $crawler = $this->client->request('GET', '/fr/admin/user/new'); - + $username = 'Test_user'. uniqid(); $password = 'Password1234!'; - dump($crawler->text()); // Fill in the form and submit it $form = $crawler->selectButton('Créer')->form(array( 'chill_mainbundle_user[username]' => $username, 'chill_mainbundle_user[plainPassword][first]' => $password, - 'chill_mainbundle_user[plainPassword][second]' => $password + 'chill_mainbundle_user[plainPassword][second]' => $password, + 'chill_mainbundle_user[email]' => $username.'@gmail.com', + 'chill_mainbundle_user[label]' => $username, + )); $this->client->submit($form); $crawler = $this->client->followRedirect(); // Check data in the show view - $this->assertGreaterThan(0, $crawler->filter('td:contains("Test_user")')->count(), + $this->assertGreaterThan(0, $crawler->filter('td:contains("Test_user")')->count(), 'Missing element td:contains("Test user")'); - + $update = $crawler->selectLink('Modifier')->link(); - + $this->assertInstanceOf('Symfony\Component\DomCrawler\Link', $update); $this->assertRegExp('|/fr/admin/user/[0-9]{1,}/edit$|', $update->getUri()); - + //test the auth of the new client $this->isPasswordValid($username, $password); - + return $update; } - + protected function isPasswordValid($username, $password) { /* @var $passwordEncoder \Symfony\Component\Security\Core\Encoder\UserPasswordEncoder */ $passwordEncoder = self::$kernel->getContainer() ->get('security.password_encoder'); - + $user = self::$kernel->getContainer() ->get('doctrine.orm.entity_manager') ->getRepository('ChillMainBundle:User') ->findOneBy(array('username' => $username)); - + $this->assertTrue($passwordEncoder->isPasswordValid($user, $password)); } - + /** - * + * * @param \Symfony\Component\DomCrawler\Link $update * @depends testNew */ @@ -90,24 +92,24 @@ class UserControllerTest extends WebTestCase $form = $crawler->selectButton('Mettre à jour')->form(array( 'chill_mainbundle_user[username]' => $username, )); - + $this->client->submit($form); $crawler = $this->client->followRedirect(); // Check the element contains an attribute with value equals "Foo" - $this->assertGreaterThan(0, $crawler->filter('[value="'.$username.'"]')->count(), + $this->assertGreaterThan(0, $crawler->filter('[value="'.$username.'"]')->count(), 'Missing element [value="Foo bar"]'); - + $updatePassword = $crawler->selectLink('Modifier le mot de passe')->link(); - + $this->assertInstanceOf('Symfony\Component\DomCrawler\Link', $updatePassword); - $this->assertRegExp('|/fr/admin/user/[0-9]{1,}/edit_password$|', + $this->assertRegExp('|/fr/admin/user/[0-9]{1,}/edit_password$|', $updatePassword->getUri()); - + return array('link' => $updatePassword, 'username' => $username); } - + /** - * + * * @param \Symfony\Component\DomCrawler\Link $updatePassword * @depends testUpdate */ @@ -116,22 +118,22 @@ class UserControllerTest extends WebTestCase $link = $params['link']; $username = $params['username']; $newPassword = '1234Password!'; - + $crawler = $this->client->click($link); - + $form = $crawler->selectButton('Changer le mot de passe')->form(array( 'chill_mainbundle_user_password[new_password][first]' => $newPassword, 'chill_mainbundle_user_password[new_password][second]' => $newPassword, )); - + $this->client->submit($form); - - $this->assertTrue($this->client->getResponse()->isRedirect(), + + $this->assertTrue($this->client->getResponse()->isRedirect(), "the response is a redirection"); $this->client->followRedirect(); - + $this->isPasswordValid($username, $newPassword); } - + } diff --git a/src/Bundle/ChillMainBundle/Tests/Security/Authorization/AuthorizationHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Security/Authorization/AuthorizationHelperTest.php index fbd3cd4d1..764d6c429 100644 --- a/src/Bundle/ChillMainBundle/Tests/Security/Authorization/AuthorizationHelperTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Security/Authorization/AuthorizationHelperTest.php @@ -19,6 +19,10 @@ namespace Chill\MainBundle\Tests\Security\Authorization; +use Chill\MainBundle\Entity\HasCenterInterface; +use Chill\MainBundle\Entity\HasCentersInterface; +use Chill\MainBundle\Entity\HasScopeInterface; +use Chill\MainBundle\Entity\HasScopesInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Chill\MainBundle\Test\PrepareUserTrait; use Chill\MainBundle\Test\PrepareCenterTrait; @@ -30,22 +34,22 @@ use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Center; /** - * + * * * @author Julien Fastré */ class AuthorizationHelperTest extends KernelTestCase { - + use PrepareUserTrait, PrepareCenterTrait, PrepareScopeTrait, ProphecyTrait; - - public function setUp() + + public function setUp() { static::bootKernel(); } - + /** - * + * * @return \Chill\MainBundle\Security\Authorization\AuthorizationHelper */ private function getAuthorizationHelper() @@ -54,13 +58,13 @@ class AuthorizationHelperTest extends KernelTestCase ->get('chill.main.security.authorization.helper') ; } - + /** * Test function userCanReach of helper. - * + * * A user can reach center => the function should return true. */ - public function testUserCanReachCenter_UserShouldReach() + public function testUserCanReachCenter_UserShouldReach() { $center = $this->prepareCenter(1, 'center'); $scope = $this->prepareScope(1, 'default'); @@ -72,16 +76,16 @@ class AuthorizationHelperTest extends KernelTestCase ) )); $helper = $this->getAuthorizationHelper(); - + $this->assertTrue($helper->userCanReachCenter($user, $center)); } - + /** * Test function userCanReach of helper - * + * * A user can not reachcenter =>W the function should return false */ - public function testUserCanReachCenter_UserShouldNotReach() + public function testUserCanReachCenter_UserShouldNotReach() { $centerA = $this->prepareCenter(1, 'center'); $centerB = $this->prepareCenter(2, 'centerB'); @@ -94,11 +98,11 @@ class AuthorizationHelperTest extends KernelTestCase ) )); $helper = $this->getAuthorizationHelper(); - + $this->assertFalse($helper->userCanReachCenter($user, $centerB)); - + } - + public function testUserHasAccess_shouldHaveAccess_EntityWithoutScope() { $center = $this->prepareCenter(1, 'center'); @@ -114,11 +118,11 @@ class AuthorizationHelperTest extends KernelTestCase $entity = $this->getProphet()->prophesize(); $entity->willImplement('\Chill\MainBundle\Entity\HasCenterInterface'); $entity->getCenter()->willReturn($center); - - $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), + + $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + public function testUserHasAccess_ShouldHaveAccessWithInheritance_EntityWithoutScope() { $center = $this->prepareCenter(1, 'center'); @@ -130,17 +134,17 @@ class AuthorizationHelperTest extends KernelTestCase ) ) )); - + $helper = $this->getAuthorizationHelper(); $entity = $this->getProphet()->prophesize(); $entity->willImplement('\Chill\MainBundle\Entity\HasCenterInterface'); $entity->getCenter()->willReturn($center); - - $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), + + $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), 'CHILL_INHERITED_ROLE_1')); } - - + + public function testuserHasAccess_UserHasNoRole_EntityWithoutScope() { $center = $this->prepareCenter(1, 'center'); @@ -156,10 +160,10 @@ class AuthorizationHelperTest extends KernelTestCase $entity = $this->getProphet()->prophesize(); $entity->willImplement('\Chill\MainBundle\Entity\HasCenterInterface'); $entity->getCenter()->willReturn($center); - + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + /** * test that a user has no access on a entity, but is granted on the same role * on another center @@ -186,10 +190,10 @@ class AuthorizationHelperTest extends KernelTestCase $entity = $this->getProphet()->prophesize(); $entity->willImplement('\Chill\MainBundle\Entity\HasCenterInterface'); $entity->getCenter()->willReturn($centerA); - + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + public function testtestUserHasAccess_UserShouldHaveAccess_EntityWithScope() { $center = $this->prepareCenter(1, 'center'); @@ -207,10 +211,10 @@ class AuthorizationHelperTest extends KernelTestCase $entity->willImplement('\Chill\MainBundle\Entity\HasScopeInterface'); $entity->getCenter()->willReturn($center); $entity->getScope()->willReturn($scope); - + $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + public function testUserHasAccess_UserHasNoRole_EntityWithScope() { $center = $this->prepareCenter(1, 'center'); @@ -228,10 +232,10 @@ class AuthorizationHelperTest extends KernelTestCase $entity->willImplement('\Chill\MainBundle\Entity\HasScopeInterface'); $entity->getCenter()->willReturn($center); $entity->getScope()->willReturn($scope); - + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'ANOTHER_ROLE')); } - + public function testUserHasAccess_UserHasNoCenter_EntityWithScope() { $centerA = $this->prepareCenter(1, 'center'); //the user will have this center @@ -250,10 +254,10 @@ class AuthorizationHelperTest extends KernelTestCase $entity->willImplement('\Chill\MainBundle\Entity\HasScopeInterface'); $entity->getCenter()->willReturn($centerB); $entity->getScope()->willReturn($scope); - + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + public function testUserHasAccess_UserHasNoScope_EntityWithScope() { $center = $this->prepareCenter(1, 'center'); @@ -268,16 +272,106 @@ class AuthorizationHelperTest extends KernelTestCase )); $helper = $this->getAuthorizationHelper(); $entity = $this->getProphet()->prophesize(); - $entity->willImplement('\Chill\MainBundle\Entity\HasCenterInterface'); - $entity->willImplement('\Chill\MainBundle\Entity\HasScopeInterface'); + $entity->willImplement(HasCenterInterface::class); + $entity->willImplement(HasScopeInterface::class); $entity->getCenter()->willReturn($center); $entity->getScope()->willReturn($scopeA); - + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); } - + + public function testUserHasAccess_MultiCenter_EntityWithoutScope() + { + $center = $this->prepareCenter(1, 'center'); + $centerB = $this->prepareCenter(1, 'centerB'); + $scopeB = $this->prepareScope(2, 'other'); //the user will be granted this scope + $user = $this->prepareUser(array( + array( + 'center' => $center, 'permissionsGroup' => array( + ['scope' => $scopeB, 'role' => 'CHILL_ROLE'] + ) + ) + )); + $helper = $this->getAuthorizationHelper(); + $entity = $this->getProphet()->prophesize(); + $entity->willImplement(HasCentersInterface::class); + $entity->getCenters()->willReturn([$center, $centerB]); + + $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); + } + + public function testUserHasNoAccess_MultiCenter_EntityWithoutScope() + { + $center = $this->prepareCenter(1, 'center'); + $centerB = $this->prepareCenter(1, 'centerB'); + $centerC = $this->prepareCenter(1, 'centerC'); + $scopeB = $this->prepareScope(2, 'other'); //the user will be granted this scope + $user = $this->prepareUser(array( + array( + 'center' => $center, 'permissionsGroup' => array( + ['scope' => $scopeB, 'role' => 'CHILL_ROLE'] + ) + ) + )); + $helper = $this->getAuthorizationHelper(); + $entity = $this->getProphet()->prophesize(); + $entity->willImplement(HasCentersInterface::class); + $entity->getCenters()->willReturn([$centerB, $centerC]); + + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); + } + + public function testUserHasNoAccess_EntityMultiScope() + { + $centerA = $this->prepareCenter(1, 'center'); + $centerB = $this->prepareCenter(1, 'centerB'); + $scopeA = $this->prepareScope(2, 'other'); //the user will be granted this scope + $scopeB = $this->prepareScope(2, 'other'); //the user will be granted this scope + $scopeC = $this->prepareScope(2, 'other'); //the user will be granted this scope + $user = $this->prepareUser(array( + array( + 'center' => $centerA, 'permissionsGroup' => array( + ['scope' => $scopeA, 'role' => 'CHILL_ROLE'] + ) + ) + )); + $helper = $this->getAuthorizationHelper(); + $entity = $this->getProphet()->prophesize(); + $entity->willImplement(HasCentersInterface::class); + $entity->willImplement(HasScopesInterface::class); + $entity->getCenters()->willReturn([$centerA, $centerB]); + $entity->getScopes()->willReturn([$scopeB, $scopeC]); + + $this->assertFalse($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); + } + + public function testUserHasAccess_EntityMultiScope() + { + $centerA = $this->prepareCenter(1, 'center'); + $centerB = $this->prepareCenter(1, 'centerB'); + $scopeA = $this->prepareScope(2, 'other'); //the user will be granted this scope + $scopeB = $this->prepareScope(2, 'other'); //the user will be granted this scope + $user = $this->prepareUser(array( + array( + 'center' => $centerA, 'permissionsGroup' => array( + ['scope' => $scopeA, 'role' => 'CHILL_ROLE'] + ) + ) + )); + $helper = $this->getAuthorizationHelper(); + $entity = $this->getProphet()->prophesize(); + $entity->willImplement(HasCentersInterface::class); + $entity->willImplement(HasScopesInterface::class); + $entity->getCenters()->willReturn([$centerA, $centerB]); + $entity->getScopes()->willReturn([$scopeA, $scopeB]); + + $this->assertTrue($helper->userHasAccess($user, $entity->reveal(), 'CHILL_ROLE')); + } + + + /** - * + * * @dataProvider dataProvider_getReachableCenters * @param Center $shouldHaveCenter * @param User $user @@ -288,7 +382,7 @@ class AuthorizationHelperTest extends KernelTestCase { $this->assertEquals($test, $result, $msg); } - + public function dataProvider_getReachableCenters() { $this->setUp(); @@ -297,10 +391,10 @@ class AuthorizationHelperTest extends KernelTestCase $scopeA = $this->prepareScope(1, 'scope default'); $scopeB = $this->prepareScope(2, 'scope B'); $scopeC = $this->prepareScope(3, 'scope C'); - + $userA = $this->prepareUser(array( array( - 'center' => $centerA, + 'center' => $centerA, 'permissionsGroup' => array( ['scope' => $scopeB, 'role' => 'CHILL_ROLE_1'], ['scope' => $scopeA, 'role' => 'CHILL_ROLE_2'] @@ -313,62 +407,62 @@ class AuthorizationHelperTest extends KernelTestCase ['scope' => $scopeC, 'role' => 'CHILL_ROLE_2'] ) ) - + )); - + $ah = $this->getAuthorizationHelper(); - + return array( // without scopes array( - true, - in_array($centerA, $ah->getReachableCenters($userA, + true, + in_array($centerA, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_1'), null)), 'center A should be available for userA, with role 1 ' ), array( - true, - in_array($centerA, $ah->getReachableCenters($userA, + true, + in_array($centerA, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_2'), null)), 'center A should be available for userA, with role 2 ' ), array( - true, - in_array($centerB, $ah->getReachableCenters($userA, + true, + in_array($centerB, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_2'), null)), 'center A should be available for userA, with role 2 ' ), array( - false, + false, in_array($centerB, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_1'), null)), 'center B should NOT be available for userA, with role 1 ' ), // with scope array( - true, - in_array($centerA, $ah->getReachableCenters($userA, + true, + in_array($centerA, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_1'), $scopeB)), 'center A should be available for userA, with role 1, scopeC ' ), array( - false, - in_array($centerA, $ah->getReachableCenters($userA, + false, + in_array($centerA, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_2'), $scopeC)), 'center A should NOT be available for userA, with role 2, scopeA ' ), array( - true, - in_array($centerB, $ah->getReachableCenters($userA, + true, + in_array($centerB, $ah->getReachableCenters($userA, new Role('CHILL_ROLE_2'), $scopeA)), 'center B should be available for userA, with role 2, scopeA ' ), ); - + } - + /** - * + * * @dataProvider dataProvider_getReachableScopes * @param boolean $expectedResult * @param Scope $testedScope @@ -382,11 +476,11 @@ class AuthorizationHelperTest extends KernelTestCase { $reachableScopes = $this->getAuthorizationHelper() ->getReachableScopes($user, $role, $center); - + $this->assertEquals($expectedResult, in_array($testedScope, $reachableScopes), $message); } - + public function dataProvider_getReachableScopes() { $centerA = $this->prepareCenter(1, 'center A'); @@ -394,10 +488,10 @@ class AuthorizationHelperTest extends KernelTestCase $scopeA = $this->prepareScope(1, 'scope default'); $scopeB = $this->prepareScope(2, 'scope B'); $scopeC = $this->prepareScope(3, 'scope C'); - + $userA = $this->prepareUser(array( array( - 'center' => $centerA, + 'center' => $centerA, 'permissionsGroup' => array( ['scope' => $scopeB, 'role' => 'CHILL_ROLE_1'], ['scope' => $scopeA, 'role' => 'CHILL_ROLE_2'] @@ -411,9 +505,9 @@ class AuthorizationHelperTest extends KernelTestCase ['scope' => $scopeB, 'role' => 'CHILL_ROLE_2'] ) ) - + )); - + return array( array( true, @@ -442,37 +536,30 @@ class AuthorizationHelperTest extends KernelTestCase ) ); } - + public function testGetParentRoles() { $parentRoles = $this->getAuthorizationHelper() - ->getParentRoles(new Role('CHILL_INHERITED_ROLE_1')); - - $this->assertContains( - 'CHILL_MASTER_ROLE', - \array_map( - function(Role $role) { - return $role->getRole(); - }, - $parentRoles - ), + ->getParentRoles('CHILL_INHERITED_ROLE_1'); + + $this->assertContains('CHILL_MASTER_ROLE', $parentRoles, "Assert that `CHILL_MASTER_ROLE` is a parent of `CHILL_INHERITED_ROLE_1`"); } - + public function testFindUsersReaching() { $centerA = static::$kernel->getContainer() ->get('doctrine.orm.entity_manager') ->getRepository(Center::class) ->findOneByName('Center A'); - + $users = $this->getAuthorizationHelper() - ->findUsersReaching(new Role('CHILL_PERSON_SEE'), + ->findUsersReaching(new Role('CHILL_PERSON_SEE'), $centerA); - + $usernames = \array_map(function(User $u) { return $u->getUsername(); }, $users); - + $this->assertContains('center a_social', $usernames); } - + } diff --git a/src/Bundle/ChillMainBundle/Tests/Security/Resolver/CenterResolverDispatcherTest.php b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/CenterResolverDispatcherTest.php new file mode 100644 index 000000000..851183e3d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/CenterResolverDispatcherTest.php @@ -0,0 +1,27 @@ +dispatcher = self::$container->get(CenterResolverDispatcher::class); + } + + public function testResolveCenter() + { + $center = new Center(); + + $resolved = $this->dispatcher->resolveCenter($center); + + $this->assertSame($center, $resolved); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Security/Resolver/DefaultScopeResolverTest.php b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/DefaultScopeResolverTest.php new file mode 100644 index 000000000..de6d19b5a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/DefaultScopeResolverTest.php @@ -0,0 +1,62 @@ +scopeResolver = new DefaultScopeResolver(); + } + + public function testHasScopeInterface() + { + $scope = new Scope(); + $entity = new class($scope) implements HasScopeInterface { + + public function __construct(Scope $scope) { + $this->scope = $scope; + } + + public function getScope() + { + return $this->scope; + } + + }; + + $this->assertTrue($this->scopeResolver->supports($entity)); + $this->assertTrue($this->scopeResolver->isConcerned($entity)); + $this->assertSame($scope, $this->scopeResolver->resolveScope($entity)); + } + + public function testHasScopesInterface() + { + $entity = new class($scopeA = new Scope(), $scopeB = new Scope()) implements HasScopesInterface { + + public function __construct(Scope $scopeA, Scope $scopeB) { + $this->scopes = [$scopeA, $scopeB]; + } + + public function getScopes(): iterable + { + return $this->scopes; + } + }; + + $this->assertTrue($this->scopeResolver->supports($entity)); + $this->assertTrue($this->scopeResolver->isConcerned($entity)); + $this->assertIsArray($this->scopeResolver->resolveScope($entity)); + $this->assertSame($scopeA, $this->scopeResolver->resolveScope($entity)[0]); + $this->assertSame($scopeB, $this->scopeResolver->resolveScope($entity)[1]); + } + +} diff --git a/src/Bundle/ChillMainBundle/Tests/Security/Resolver/ScopeResolverDispatcherTest.php b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/ScopeResolverDispatcherTest.php new file mode 100644 index 000000000..2a36fd365 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Security/Resolver/ScopeResolverDispatcherTest.php @@ -0,0 +1,60 @@ +scopeResolverDispatcher = new ScopeResolverDispatcher([new DefaultScopeResolver()]); + } + + public function testHasScopeInterface() + { + $scope = new Scope(); + $entity = new class($scope) implements HasScopeInterface { + + public function __construct(Scope $scope) { + $this->scope = $scope; + } + + public function getScope() + { + return $this->scope; + } + + }; + + $this->assertTrue($this->scopeResolverDispatcher->isConcerned($entity)); + $this->assertSame($scope, $this->scopeResolverDispatcher->resolveScope($entity)); + } + + public function testHasScopesInterface() + { + $entity = new class($scopeA = new Scope(), $scopeB = new Scope()) implements HasScopesInterface { + + public function __construct(Scope $scopeA, Scope $scopeB) { + $this->scopes = [$scopeA, $scopeB]; + } + + public function getScopes(): iterable + { + return $this->scopes; + } + }; + + $this->assertTrue($this->scopeResolverDispatcher->isConcerned($entity)); + $this->assertIsArray($this->scopeResolverDispatcher->resolveScope($entity)); + $this->assertSame($scopeA, $this->scopeResolverDispatcher->resolveScope($entity)[0]); + $this->assertSame($scopeB, $this->scopeResolverDispatcher->resolveScope($entity)[1]); + } +} diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index 3dd8c8736..8ee69e96d 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -480,3 +480,34 @@ paths: description: "not found" 401: description: "Unauthorized" + + /1.0/main/scope.json: + get: + tags: + - scope + summary: return a list of scopes + responses: + 200: + description: "ok" + 401: + description: "Unauthorized" + + /1.0/main/scope/{id}.json: + get: + tags: + - scope + summary: return a list of scopes + parameters: + - name: id + in: path + required: true + description: The scope id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 401: + description: "Unauthorized" diff --git a/src/Bundle/ChillMainBundle/config/services/form.yaml b/src/Bundle/ChillMainBundle/config/services/form.yaml index bd5c1ece5..f719edb55 100644 --- a/src/Bundle/ChillMainBundle/config/services/form.yaml +++ b/src/Bundle/ChillMainBundle/config/services/form.yaml @@ -113,6 +113,10 @@ services: tags: - { name: form.type } + Chill\MainBundle\Form\UserType: + autowire: true + autoconfigure: true + Chill\MainBundle\Form\PermissionsGroupType: tags: - { name: form.type } @@ -123,3 +127,4 @@ services: - "@security.token_storage" tags: - { name: form.type } + diff --git a/src/Bundle/ChillMainBundle/config/services/security.yaml b/src/Bundle/ChillMainBundle/config/services/security.yaml index 474ac0093..0d820220b 100644 --- a/src/Bundle/ChillMainBundle/config/services/security.yaml +++ b/src/Bundle/ChillMainBundle/config/services/security.yaml @@ -3,16 +3,45 @@ services: autowire: true autoconfigure: true + # do not autowire the directory Security/Resolver + Chill\MainBundle\Security\Resolver\CenterResolverDispatcher: + arguments: + - !tagged_iterator chill_main.center_resolver + + Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher: + arguments: + - !tagged_iterator chill_main.scope_resolver + + # do not autowire the directory Security/Resolver + Chill\MainBundle\Security\Resolver\DefaultCenterResolver: + autoconfigure: true + autowire: true + + Chill\MainBundle\Security\Resolver\DefaultScopeResolver: + autoconfigure: true + autowire: true + + # do not autowire the directory Security/Resolver + Chill\MainBundle\Security\Resolver\ResolverTwigExtension: + autoconfigure: true + autowire: true + + # do not autowire the directory Security/Resolver + Chill\MainBundle\Security\Authorization\DefaultVoterHelperFactory: + autowire: true + + # do not autowire the directory Security/Resolver + Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface: '@Chill\MainBundle\Security\Authorization\DefaultVoterHelperFactory' + chill.main.security.authorization.helper: class: Chill\MainBundle\Security\Authorization\AuthorizationHelper - arguments: - $roleHierarchy: "@security.role_hierarchy" - $hierarchy: "%security.role_hierarchy.roles%" - $em: '@Doctrine\ORM\EntityManagerInterface' + autowire: true + autoconfigure: true Chill\MainBundle\Security\Authorization\AuthorizationHelper: '@chill.main.security.authorization.helper' chill.main.role_provider: class: Chill\MainBundle\Security\RoleProvider + Chill\MainBundle\Security\RoleProvider: '@chill.main.role_provider' chill.main.user_provider: class: Chill\MainBundle\Security\UserProvider\UserProvider diff --git a/src/Bundle/ChillMainBundle/config/validation.yaml b/src/Bundle/ChillMainBundle/config/validation.yaml index dd5f8d985..79cf4d8f9 100644 --- a/src/Bundle/ChillMainBundle/config/validation.yaml +++ b/src/Bundle/ChillMainBundle/config/validation.yaml @@ -18,6 +18,8 @@ Chill\MainBundle\Entity\User: min: 3 email: - Email: ~ + label: + - NotBlank: ~ constraints: - Callback: callback: isGroupCenterPresentOnce diff --git a/src/Bundle/ChillMainBundle/migrations/Version20210903144853.php b/src/Bundle/ChillMainBundle/migrations/Version20210903144853.php new file mode 100644 index 000000000..7fe6b8e72 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20210903144853.php @@ -0,0 +1,52 @@ +addSql('CREATE SEQUENCE chill_main_user_job_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_main_user_job (id INT NOT NULL, label JSON NOT NULL, active BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('ALTER TABLE users ADD label VARCHAR(200) NULL DEFAULT NULL'); + $this->addSql('UPDATE users SET label=username'); + $this->addSql('ALTER TABLE users ALTER label DROP DEFAULT'); + $this->addSql('ALTER TABLE users ALTER label SET NOT NULL'); + $this->addSql('ALTER TABLE users ADD mainCenter_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD mainScope_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD userJob_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E92C2125C1 FOREIGN KEY (mainCenter_id) REFERENCES centers (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9115E73F3 FOREIGN KEY (mainScope_id) REFERENCES scopes (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E964B65C5B FOREIGN KEY (userJob_id) REFERENCES chill_main_user_job (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_1483A5E92C2125C1 ON users (mainCenter_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E9115E73F3 ON users (mainScope_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E964B65C5B ON users (userJob_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP CONSTRAINT FK_1483A5E964B65C5B'); + $this->addSql('DROP SEQUENCE chill_main_user_job_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_main_user_job'); + $this->addSql('ALTER TABLE users DROP CONSTRAINT FK_1483A5E92C2125C1'); + $this->addSql('ALTER TABLE users DROP CONSTRAINT FK_1483A5E9115E73F3'); + $this->addSql('ALTER TABLE users DROP label'); + $this->addSql('ALTER TABLE users DROP mainCenter_id'); + $this->addSql('ALTER TABLE users DROP mainScope_id'); + $this->addSql('ALTER TABLE users DROP userJob_id'); + $this->addSql('ALTER TABLE users ALTER usernameCanonical DROP NOT NULL'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index b230b6ac1..59dcd8c73 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -177,6 +177,7 @@ Exports list: Liste des exports Create an export: Créer un export #export creation step 'center' : pick a center Pick centers: Choisir les centres +Pick a center: Choisir un centre The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis. This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis. Go to export options: Vers la préparation de l'export diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php index 2f086c9f5..1bf40d146 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php @@ -73,7 +73,7 @@ class AccompanyingCourseController extends Controller } } - $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $period); + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::CREATE, $period); $em->persist($period); $em->flush(); @@ -92,6 +92,8 @@ class AccompanyingCourseController extends Controller */ public function indexAction(AccompanyingPeriod $accompanyingCourse): Response { + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $accompanyingCourse); + // compute some warnings // get persons without household $withoutHousehold = []; @@ -131,6 +133,8 @@ class AccompanyingCourseController extends Controller */ public function editAction(AccompanyingPeriod $accompanyingCourse): Response { + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $accompanyingCourse); + return $this->render('@ChillPerson/AccompanyingCourse/edit.html.twig', [ 'accompanyingCourse' => $accompanyingCourse ]); @@ -146,6 +150,8 @@ class AccompanyingCourseController extends Controller */ public function historyAction(AccompanyingPeriod $accompanyingCourse): Response { + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $accompanyingCourse); + return $this->render('@ChillPerson/AccompanyingCourse/history.html.twig', [ 'accompanyingCourse' => $accompanyingCourse ]); diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php index f31dfa585..255c6d9ba 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php @@ -23,7 +23,10 @@ namespace Chill\PersonBundle\Controller; use Chill\PersonBundle\Privacy\PrivacyEvent; +use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Doctrine\DBAL\Exception; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Form\AccompanyingPeriodType; @@ -53,21 +56,24 @@ class AccompanyingPeriodController extends AbstractController */ protected $validator; - /** - * AccompanyingPeriodController constructor. - * - * @param EventDispatcherInterface $eventDispatcher - * @param ValidatorInterface $validator - */ - public function __construct(EventDispatcherInterface $eventDispatcher, ValidatorInterface $validator) - { + protected AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository; + + public function __construct( + AccompanyingPeriodACLAwareRepositoryInterface $accompanyingPeriodACLAwareRepository, + EventDispatcherInterface $eventDispatcher, + ValidatorInterface $validator + ) { + $this->accompanyingPeriodACLAwareRepository = $accompanyingPeriodACLAwareRepository; $this->eventDispatcher = $eventDispatcher; $this->validator = $validator; } - public function listAction(int $person_id): Response + /** + * @ParamConverter("person", options={"id"="person_id"}) + */ + public function listAction(Person $person): Response { - $person = $this->_getPerson($person_id); + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $person); $event = new PrivacyEvent($person, [ 'element_class' => AccompanyingPeriod::class, @@ -75,9 +81,10 @@ class AccompanyingPeriodController extends AbstractController ]); $this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event); - $accompanyingPeriods = $person->getAccompanyingPeriodsOrdered(); + $accompanyingPeriods = $this->accompanyingPeriodACLAwareRepository + ->findByPerson($person, AccompanyingPeriodVoter::SEE); - return $this->render('ChillPersonBundle:AccompanyingPeriod:list.html.twig', [ + return $this->render('@ChillPerson/AccompanyingPeriod/list.html.twig', [ 'accompanying_periods' => $accompanyingPeriods, 'person' => $person ]); diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonController.php b/src/Bundle/ChillPersonBundle/Controller/PersonController.php index 759f892b2..03da97d5f 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonController.php @@ -230,13 +230,16 @@ final class PersonController extends AbstractController */ public function newAction(Request $request) { - $defaultCenter = $this->security - ->getUser() - ->getGroupCenters()[0] - ->getCenter(); + $person = new Person(); - $person = (new Person(new \DateTime('now'))) - ->setCenter($defaultCenter); + if (1 === count($this->security->getUser() + ->getGroupCenters())) { + $person->setCenter( + $this->security->getUser() + ->getGroupCenters()[0] + ->getCenter() + ); + } $form = $this->createForm(CreationPersonType::class, $person, [ 'validation_groups' => ['create'] diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php index d6bdce233..848e8f874 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php @@ -24,8 +24,12 @@ namespace Chill\PersonBundle\DataFixtures\ORM; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Country; use Chill\MainBundle\Entity\PostalCode; +use Chill\MainBundle\Entity\Scope; +use Chill\MainBundle\Entity\User; use Chill\MainBundle\Repository\CenterRepository; use Chill\MainBundle\Repository\CountryRepository; +use Chill\MainBundle\Repository\ScopeRepository; +use Chill\MainBundle\Repository\UserRepository; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\MaritalStatus; use Chill\PersonBundle\Entity\SocialWork\SocialIssue; @@ -90,12 +94,26 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con protected MaritalStatusRepository $maritalStatusRepository; + /** + * @var array|Scope[] + */ + protected array $cacheScopes = []; + + protected ScopeRepository $scopeRepository; + + /** @var array|User[] */ + protected array $cacheUsers = []; + + protected UserRepository $userRepository; + public function __construct( Registry $workflowRegistry, SocialIssueRepository $socialIssueRepository, CenterRepository $centerRepository, CountryRepository $countryRepository, - MaritalStatusRepository $maritalStatusRepository + MaritalStatusRepository $maritalStatusRepository, + ScopeRepository $scopeRepository, + UserRepository $userRepository ) { $this->faker = Factory::create('fr_FR'); $this->faker->addProvider($this); @@ -105,7 +123,8 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con $this->countryRepository = $countryRepository; $this->maritalStatusRepository = $maritalStatusRepository; $this->loader = new NativeLoader($this->faker); - + $this->scopeRepository = $scopeRepository; + $this->userRepository = $userRepository; } public function getOrder() @@ -220,10 +239,16 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con new \DateInterval('P' . \random_int(0, 180) . 'D') ) ); + $accompanyingPeriod->setCreatedBy($this->getRandomUser()) + ->setCreatedAt(new \DateTimeImmutable('now')); $person->addAccompanyingPeriod($accompanyingPeriod); $accompanyingPeriod->addSocialIssue($this->getRandomSocialIssue()); if (\random_int(0, 10) > 3) { + // always add social scope: + $accompanyingPeriod->addScope($this->getReference('scope_social')); + var_dump(count($accompanyingPeriod->getScopes())); + $accompanyingPeriod->setAddressLocation($this->createAddress()); $manager->persist($accompanyingPeriod->getAddressLocation()); $workflow = $this->workflowRegistry->get($accompanyingPeriod); @@ -231,9 +256,19 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con } $manager->persist($person); + $manager->persist($accompanyingPeriod); echo "add person'".$person->__toString()."'\n"; } + private function getRandomUser(): User + { + if (0 === count($this->cacheUsers)) { + $this->cacheUsers = $this->userRepository->findAll(); + } + + return $this->cacheUsers[\array_rand($this->cacheUsers)]; + } + private function createAddress(): Address { $objectSet = $this->loader->loadData([ diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php index c8593d1fd..18f8e5879 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php @@ -40,13 +40,13 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface return 9600; } - + public function load(ObjectManager $manager) { foreach (LoadPermissionsGroup::$refs as $permissionsGroupRef) { $permissionsGroup = $this->getReference($permissionsGroupRef); $scopeSocial = $this->getReference('scope_social'); - + //create permission group switch ($permissionsGroup->getName()) { case 'social': @@ -55,7 +55,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $permissionsGroup->addRoleScope( (new RoleScope()) - ->setRole(AccompanyingPeriodVoter::SEE) + ->setRole(AccompanyingPeriodVoter::FULL) ->setScope($scopeSocial) ); @@ -87,7 +87,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $manager->persist($roleScopeUpdate); $manager->persist($roleScopeCreate); $manager->persist($roleScopeDuplicate); - + break; case 'administrative': printf("Adding CHILL_PERSON_SEE to %s permission group \n", $permissionsGroup->getName()); @@ -98,9 +98,9 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface $manager->persist($roleScopeSee); break; } - + } - + $manager->flush(); } diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 07d5dc88f..485185ce7 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -18,6 +18,7 @@ namespace Chill\PersonBundle\DependencyInjection; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -60,6 +61,9 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac $container->setParameter('chill_person.allow_multiple_simultaneous_accompanying_periods', $config['allow_multiple_simultaneous_accompanying_periods']); + // register all configuration in a unique parameter + $container->setParameter('chill_person', $config); + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader->load('services.yaml'); $loader->load('services/widgets.yaml'); @@ -255,14 +259,26 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac */ protected function prependRoleHierarchy(ContainerBuilder $container) { - $container->prependExtensionConfig('security', array( - 'role_hierarchy' => array( - 'CHILL_PERSON_UPDATE' => array('CHILL_PERSON_SEE'), - 'CHILL_PERSON_CREATE' => array('CHILL_PERSON_SEE'), - PersonVoter::LISTS => [ ChillExportVoter::EXPORT ], - PersonVoter::STATS => [ ChillExportVoter::EXPORT ] - ) - )); + $container->prependExtensionConfig('security', [ + 'role_hierarchy' => [ + PersonVoter::UPDATE => [PersonVoter::SEE], + PersonVoter::CREATE => [PersonVoter::SEE], + PersonVoter::LISTS => [ChillExportVoter::EXPORT], + PersonVoter::STATS => [ChillExportVoter::EXPORT], + // accompanying period + AccompanyingPeriodVoter::SEE_DETAILS => [AccompanyingPeriodVoter::SEE], + AccompanyingPeriodVoter::CREATE => [AccompanyingPeriodVoter::SEE_DETAILS], + AccompanyingPeriodVoter::DELETE => [AccompanyingPeriodVoter::SEE_DETAILS], + AccompanyingPeriodVoter::EDIT => [AccompanyingPeriodVoter::SEE_DETAILS], + // give all ACL for FULL + AccompanyingPeriodVoter::FULL => [ + AccompanyingPeriodVoter::SEE_DETAILS, + AccompanyingPeriodVoter::CREATE, + AccompanyingPeriodVoter::EDIT, + AccompanyingPeriodVoter::DELETE + ] + ] + ]); } /** diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php index 927612d5f..bf2d18922 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php @@ -43,23 +43,26 @@ class Configuration implements ConfigurationInterface ->arrayNode('validation') ->canBeDisabled() ->children() + ->booleanNode('center_required') + ->info('Enable a center for each person entity. If disabled, you must provide your own center provider') + ->defaultValue(true) + ->end() ->scalarNode('birthdate_not_after') - ->info($this->validationBirthdateNotAfterInfos) - ->defaultValue('P1D') - ->validate() - ->ifTrue(function($period) { - try { - $interval = new \DateInterval($period); - } catch (\Exception $ex) { - return true; - } - return false; - }) - ->thenInvalid('Invalid period for birthdate validation : "%s" ' - . 'The parameter should match duration as defined by ISO8601 : ' - . 'https://en.wikipedia.org/wiki/ISO_8601#Durations') + ->info($this->validationBirthdateNotAfterInfos) + ->defaultValue('P1D') + ->validate() + ->ifTrue(function($period) { + try { + $interval = new \DateInterval($period); + } catch (\Exception $ex) { + return true; + } + return false; + }) + ->thenInvalid('Invalid period for birthdate validation : "%s" ' + . 'The parameter should match duration as defined by ISO8601 : ' + . 'https://en.wikipedia.org/wiki/ISO_8601#Durations') ->end() // birthdate_not_after, parent = children of validation - ->end() // children for 'validation', parent = validation ->end() //validation, parent = children of root ->end() // children of root, parent = root diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index 1c375d051..14fb26f1a 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -24,6 +24,8 @@ namespace Chill\PersonBundle\Entity; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Entity\HasCentersInterface; +use Chill\MainBundle\Entity\HasScopesInterface; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Address; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; @@ -52,7 +54,8 @@ use Symfony\Component\Validator\Constraints as Assert; * "accompanying_period"=AccompanyingPeriod::class * }) */ -class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface +class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface, + HasScopesInterface, HasCentersInterface { /** * Mark an accompanying period as "occasional" @@ -809,14 +812,21 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface return $this; } + /** + * @return iterable|Collection + */ public function getScopes(): Collection { return $this->scopes; } + + public function addScope(Scope $scope): self { - $this->scopes[] = $scope; + if (!$this->scopes->contains($scope)) { + $this->scopes[] = $scope; + } return $this; } @@ -1040,4 +1050,16 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface return 'none'; } } + + public function getCenters(): ?iterable + { + foreach ($this->getPersons() as $person) { + if (!in_array($person->getCenter(), $centers ?? []) + && NULL !== $person->getCenter()) { + $centers[] = $person->getCenter(); + } + } + + return $centers ?? null; + } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/PersonHouseholdAddress.php b/src/Bundle/ChillPersonBundle/Entity/Household/PersonHouseholdAddress.php index 511416a0e..28235b521 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/PersonHouseholdAddress.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/PersonHouseholdAddress.php @@ -8,6 +8,29 @@ use Chill\PersonBundle\Entity\Person; use Doctrine\ORM\Mapping as ORM; /** + * This class links a person to the history of his addresses, through + * household membership. + * + * It is optimized on DB side, and compute the start date and end date + * of each address by the belonging of household. + * + * **note**: the start date and end date are the date of belonging to the address, + * not the belonging of the household. + * + * Example: + * + * * person A is member of household W from 2021-01-01 to 2021-12-01 + * * person A is member of household V from 2021-12-01, still present after + * * household W lives in address Q from 2020-06-01 to 2021-06-01 + * * household W lives in address R from 2021-06-01 to 2022-06-01 + * * household V lives in address T from 2021-12-01 to still living there after + * + * The person A will have those 3 entities: + * + * 1. 1st entity: from 2021-01-01 to 2021-06-01, household W, address Q; + * 2. 2st entity: from 2021-06-01 to 2021-12-01, household W, address R; + * 3. 3st entity: from 2021-12-01 to NULL, household V, address T; + * * @ORM\Entity(readOnly=true) * @ORM\Table(name="view_chill_person_household_address") */ @@ -45,11 +68,23 @@ class PersonHouseholdAddress */ private $address; + /** + * The start date of the intersection address/household + * + * (this is not the startdate of the household, not + * the startdate of the address) + */ public function getValidFrom(): ?\DateTimeInterface { return $this->validFrom; } + /** + * The end date of the intersection address/household + * + * (this is not the enddate of the household, not + * the enddate of the address) + */ public function getValidTo(): ?\DateTimeImmutable { return $this->validTo; diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 35db2ef83..2b0b5edbd 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -35,6 +35,7 @@ use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\MainBundle\Entity\HasCenterInterface; use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; +use Chill\PersonBundle\Entity\Person\PersonCurrentAddress; use DateTime; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\Collection; @@ -43,6 +44,11 @@ use Doctrine\Common\Collections\Criteria; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; +use Symfony\Component\Validator\Constraints as Assert; +use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; +use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; +use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter; +use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential; /** * Person Class @@ -57,6 +63,12 @@ use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; * @DiscriminatorMap(typeProperty="type", mapping={ * "person"=Person::class * }) + * @PersonHasCenter( + * groups={"general", "creation"} + * ) + * @HouseholdMembershipSequential( + * groups={"household_memberships"} + * ) */ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateInterface { @@ -75,6 +87,13 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="string", length=255) + * @Assert\NotBlank( + * groups={"general", "creation"} + * ) + * @Assert\Length( + * max=255, + * groups={"general", "creation"} + * ) */ private $firstName; @@ -83,6 +102,13 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="string", length=255) + * @Assert\NotBlank( + * groups={"general", "creation"} + * ) + * @Assert\Length( + * max=255, + * groups={"general", "creation"} + * ) */ private $lastName; @@ -102,6 +128,12 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var \DateTime * * @ORM\Column(type="date", nullable=true) + * @Assert\Date( + * groups={"general", "creation"} + * ) + * @Birthdate( + * groups={"general", "creation"} + * ) */ private $birthdate; @@ -110,6 +142,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var \DateTimeImmutable * * @ORM\Column(type="date_immutable", nullable=true) + * @Assert\Date( + * groups={"general", "creation"} + * ) */ private ?\DateTimeImmutable $deathdate; @@ -150,6 +185,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="string", length=9, nullable=true) + * @Assert\NotNull( + * groups={"general", "creation"} + * ) */ private $gender; @@ -179,8 +217,11 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var \DateTime * * @ORM\Column(type="date", nullable=true) + * @Assert\Date( + * groups={"general", "creation"} + * ) */ - private $maritalStatusDate; + private ?\DateTime $maritalStatusDate; /** * Comment on marital status @@ -202,6 +243,10 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="text", nullable=true) + * @Assert\Email( + * checkMX=true, + * groups={"general", "creation"} + * ) */ private $email = ''; @@ -210,6 +255,14 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="text", length=40, nullable=true) + * @Assert\Regex( + * pattern="/^([\+{1}])([0-9\s*]{4,20})$/", + * groups={"general", "creation"} + * ) + * @PhonenumberConstraint( + * type="landline", + * groups={"general", "creation"} + * ) */ private $phonenumber = ''; @@ -218,6 +271,14 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var string * * @ORM\Column(type="text", length=40, nullable=true) + * @Assert\Regex( + * pattern="/^([\+{1}])([0-9\s*]{4,20})$/", + * groups={"general", "creation"} + * ) + * @PhonenumberConstraint( + * type="mobile", + * groups={"general", "creation"} + * ) */ private $mobilenumber = ''; @@ -230,12 +291,13 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * cascade={"persist", "remove", "merge", "detach"}, * orphanRemoval=true * ) + * @Assert\Valid( + * traverse=true, + * groups={"general", "creation"} + * ) */ private $otherPhoneNumbers; - //TO-ADD caseOpeningDate - //TO-ADD nativeLanguag - /** * The person's spoken languages * @var ArrayCollection @@ -254,7 +316,6 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @var Center * * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center") - * @ORM\JoinColumn(nullable=false) */ private $center; @@ -353,6 +414,18 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI private $addresses; /** + * The current person address. + * + * This is computed through database and is optimized on database side. + * + * @var PersonCurrentAddress|null + * @ORM\OneToOne(targetEntity=PersonCurrentAddress::class, mappedBy="person") + */ + private ?PersonCurrentAddress $currentPersonAddress = null; + + /** + * fullname canonical. Read-only field, which is calculated by + * the database. * @var string * * @ORM\Column(type="text", nullable=true) @@ -373,6 +446,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI private array $currentHouseholdAt = []; /** + * Read-only field, computed by the database + * * @ORM\OneToMany( * targetEntity=PersonHouseholdAddress::class, * mappedBy="person" @@ -390,8 +465,6 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI /** * Person constructor. - * - * @param \DateTime|null $opening */ public function __construct() { @@ -404,6 +477,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI $this->householdAddresses = new ArrayCollection(); $this->genderComment = new CommentEmbeddable(); $this->maritalStatusComment = new CommentEmbeddable(); + $this->periodLocatedOn = new ArrayCollection(); } /** @@ -501,6 +575,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $participation->getAccompanyingPeriod(); } } + + return null; } /** @@ -1179,13 +1255,31 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $this->addresses; } + /** + * @deprecated Use `getCurrentPersonAddress` instead + * @param DateTime|null $from + * @return false|mixed|null + * @throws \Exception + */ public function getLastAddress(DateTime $from = null) { - $from ??= new DateTime('now'); + return $this->getCurrentPersonAddress($from); + } + + /** + * get the address associated with the person at the given date + * + * @param DateTime|null $at + * @return Address|null + * @throws \Exception + */ + public function getCurrentPersonAddress(?\DateTime $at = null): ?Address + { + $at ??= new DateTime('now'); /** @var ArrayIterator $addressesIterator */ $addressesIterator = $this->getAddresses() - ->filter(static fn (Address $address): bool => $address->getValidFrom() <= $from) + ->filter(static fn (Address $address): bool => $address->getValidFrom() <= $at) ->getIterator(); $addressesIterator->uasort( @@ -1201,6 +1295,10 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * Validation callback that checks if the accompanying periods are valid * * This method add violation errors. + * + * @Assert\Callback( + * groups={"accompanying_period_consistent"} + * ) */ public function isAccompanyingPeriodValid(ExecutionContextInterface $context) { @@ -1246,6 +1344,10 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * two addresses with the same validFrom date) * * This method add violation errors. + * + * @Assert\Callback( + * groups={"addresses_consistent"} + * ) */ public function isAddressesValid(ExecutionContextInterface $context) { @@ -1425,7 +1527,16 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI public function getCurrentHouseholdAddress(?\DateTimeImmutable $at = null): ?Address { - $at = $at === null ? new \DateTimeImmutable('today') : $at; + if ( + NULL === $at + || + $at->format('Ymd') === (new \DateTime('today'))->format('Ymd') + ) { + return $this->currentPersonAddress instanceof PersonCurrentAddress + ? $this->currentPersonAddress->getAddress() : NULL; + } + + // if not now, compute the date from history $criteria = new Criteria(); $expr = Criteria::expr(); diff --git a/src/Bundle/ChillPersonBundle/Entity/Person/PersonCurrentAddress.php b/src/Bundle/ChillPersonBundle/Entity/Person/PersonCurrentAddress.php new file mode 100644 index 000000000..e982f8b97 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/Person/PersonCurrentAddress.php @@ -0,0 +1,82 @@ +person; + } + + /** + * @return Address + */ + public function getAddress(): Address + { + return $this->address; + } + + /** + * This date is the intersection of household membership + * and address validity + * + * @return \DateTimeImmutable + */ + public function getValidFrom(): \DateTimeImmutable + { + return $this->validFrom; + } + + /** + * This date is the intersection of household membership + * and address validity + * + * @return \DateTimeImmutable|null + */ + public function getValidTo(): ?\DateTimeImmutable + { + return $this->validTo; + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php b/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php index 32b7a69f2..36d088c25 100644 --- a/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php @@ -21,7 +21,9 @@ namespace Chill\PersonBundle\Form; +use Chill\MainBundle\Form\Event\CustomizeFormEvent; use Chill\PersonBundle\Entity\Person; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -33,6 +35,7 @@ use Chill\PersonBundle\Form\Type\GenderType; use Chill\MainBundle\Form\Type\DataTransformer\CenterTransformer; use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; use Chill\PersonBundle\Form\Type\PersonAltNameType; +use Chill\MainBundle\Form\Type\Export\PickCenterType; final class CreationPersonType extends AbstractType { @@ -40,10 +43,6 @@ final class CreationPersonType extends AbstractType // TODO: See if this is still valid and update accordingly. const NAME = 'chill_personbundle_person_creation'; - const FORM_NOT_REVIEWED = 'not_reviewed'; - const FORM_REVIEWED = 'reviewed' ; - const FORM_BEING_REVIEWED = 'being_reviewed'; - /** * * @var CenterTransformer @@ -56,12 +55,16 @@ final class CreationPersonType extends AbstractType */ protected $configPersonAltNamesHelper; + private EventDispatcherInterface $dispatcher; + public function __construct( CenterTransformer $centerTransformer, - ConfigPersonAltNamesHelper $configPersonAltNamesHelper + ConfigPersonAltNamesHelper $configPersonAltNamesHelper, + EventDispatcherInterface $dispatcher ) { $this->centerTransformer = $centerTransformer; $this->configPersonAltNamesHelper = $configPersonAltNamesHelper; + $this->dispatcher = $dispatcher; } /** @@ -79,7 +82,9 @@ final class CreationPersonType extends AbstractType ->add('gender', GenderType::class, array( 'required' => true, 'placeholder' => null )) - ->add('center', CenterType::class) + ->add('center', CenterType::class, [ + 'required' => false + ]) ; if ($this->configPersonAltNamesHelper->hasAltNames()) { @@ -87,6 +92,11 @@ final class CreationPersonType extends AbstractType 'by_reference' => false ]); } + + $this->dispatcher->dispatch( + new CustomizeFormEvent(static::class, $builder), + CustomizeFormEvent::NAME + ); } /** diff --git a/src/Bundle/ChillPersonBundle/Form/Type/Select2MaritalStatusType.php b/src/Bundle/ChillPersonBundle/Form/Type/Select2MaritalStatusType.php index 0cff8ee51..34fd9897e 100644 --- a/src/Bundle/ChillPersonBundle/Form/Type/Select2MaritalStatusType.php +++ b/src/Bundle/ChillPersonBundle/Form/Type/Select2MaritalStatusType.php @@ -20,6 +20,9 @@ namespace Chill\PersonBundle\Form\Type; +use Chill\MainBundle\Templating\TranslatableStringHelper; +use Chill\PersonBundle\Entity\MaritalStatus; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolver; use Chill\MainBundle\Form\Type\DataTransformer\ObjectToIdTransformer; @@ -35,15 +38,13 @@ use Chill\MainBundle\Form\Type\Select2ChoiceType; */ class Select2MaritalStatusType extends AbstractType { - /** @var RequestStack */ - private $requestStack; + private EntityManagerInterface $em; - /** @var ObjectManager */ - private $em; + private TranslatableStringHelper $translatableStringHelper; - public function __construct(RequestStack $requestStack,ObjectManager $em) + public function __construct(TranslatableStringHelper $translatableStringHelper, EntityManagerInterface $em) { - $this->requestStack = $requestStack; + $this->translatableStringHelper = $translatableStringHelper; $this->em = $em; } @@ -63,18 +64,17 @@ class Select2MaritalStatusType extends AbstractType public function configureOptions(OptionsResolver $resolver) { - $locale = $this->requestStack->getCurrentRequest()->getLocale(); $maritalStatuses = $this->em->getRepository('Chill\PersonBundle\Entity\MaritalStatus')->findAll(); $choices = array(); foreach ($maritalStatuses as $ms) { - $choices[$ms->getId()] = $ms->getName()[$locale]; + $choices[$ms->getId()] = $this->translatableStringHelper->localize($ms->getName()); } asort($choices, SORT_STRING | SORT_FLAG_CASE); $resolver->setDefaults(array( - 'class' => 'Chill\PersonBundle\Entity\MaritalStatus', + 'class' => MaritalStatus::class, 'choices' => array_combine(array_values($choices),array_keys($choices)) )); } diff --git a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php index fdb8feb3e..0c83d8a6a 100644 --- a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php +++ b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php @@ -18,14 +18,17 @@ namespace Chill\PersonBundle\Menu; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Knp\Menu\MenuItem; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; /** * Add menu entrie to person menu. - * + * * Menu entries added : - * + * * - person details ; * - accompanying period (if `visible`) * @@ -37,21 +40,25 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface * @var string 'visible' or 'hidden' */ protected $showAccompanyingPeriod; - + /** * * @var TranslatorInterface */ protected $translator; - + + private Security $security; + public function __construct( - $showAccompanyingPeriod, + ParameterBagInterface $parameterBag, + Security $security, TranslatorInterface $translator ) { - $this->showAccompanyingPeriod = $showAccompanyingPeriod; + $this->showAccompanyingPeriod = $parameterBag->get('chill_person.accompanying_period'); + $this->security = $security; $this->translator = $translator; } - + public function buildMenu($menuId, MenuItem $menu, array $parameters) { $menu->addChild($this->translator->trans('Person details'), [ @@ -83,8 +90,10 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface ->setExtras([ 'order' => 99999 ]); - - if ($this->showAccompanyingPeriod === 'visible') { + + if ($this->showAccompanyingPeriod === 'visible' + && $this->security->isGranted(AccompanyingPeriodVoter::SEE, $parameters['person']) + ) { $menu->addChild($this->translator->trans('Accompanying period list'), [ 'route' => 'chill_person_accompanying_period_list', 'routeParameters' => [ diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php new file mode 100644 index 000000000..c8cca82f5 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php @@ -0,0 +1,76 @@ +accompanyingPeriodRepository = $accompanyingPeriodRepository; + $this->security = $security; + $this->authorizationHelper = $authorizationHelper; + $this->centerResolverDispatcher = $centerResolverDispatcher; + } + + public function findByPerson( + Person $person, + string $role, + ?array $orderBy = [], + int $limit = null, + int $offset = null + ): array { + $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); + $scopes = $this->authorizationHelper + ->getReachableCircles($this->security->getUser(), $role, + $this->centerResolverDispatcher->resolveCenter($person)); + + if (0 === count($scopes)) { + return []; + } + + $qb + ->join('ap.participations', 'participation') + ->where($qb->expr()->eq('participation.person', ':person')) + ->andWhere( + $qb->expr()->orX( + 'ap.confidential = FALSE', + $qb->expr()->eq('ap.user', ':user') + ) + ) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->neq('ap.step', ':draft'), + $qb->expr()->eq('ap.createdBy', ':creator') + ) + ) + ->setParameter('draft', AccompanyingPeriod::STEP_DRAFT) + ->setParameter('person', $person) + ->setParameter('user', $this->security->getUser()) + ->setParameter('creator', $this->security->getUser()) + ; + // add join condition for scopes + $orx = $qb->expr()->orX( + $qb->expr()->eq('ap.step', ':draft') + ); + + foreach ($scopes as $key => $scope) { + $orx->add($qb->expr()->isMemberOf(':scope_'.$key, 'ap.scopes')); + $qb->setParameter('scope_'.$key, $scope); + } + $qb->andWhere($orx); + + return $qb->getQuery()->getResult(); + } + +} diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php new file mode 100644 index 000000000..a5798d3e8 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php @@ -0,0 +1,16 @@ +findOneBy($criteria); } + public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder + { + return $this->repository->createQueryBuilder($alias, $indexBy); + } + public function getClassName() { return AccompanyingPeriod::class; diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php new file mode 100644 index 000000000..d15e8cd7a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php @@ -0,0 +1,292 @@ +security = $security; + $this->em = $em; + $this->countryRepository = $countryRepository; + $this->authorizationHelper = $authorizationHelper; + } + + /** + * @return array|Person[] + * @throws NonUniqueResultException + * @throws ParsingException + */ + public function findBySearchCriteria( + int $start, + int $limit, + bool $simplify = false, + string $default = null, + string $firstname = null, + string $lastname = null, + ?\DateTime $birthdate = null, + ?\DateTime $birthdateBefore = null, + ?\DateTime $birthdateAfter = null, + string $gender = null, + string $countryCode = null + ): array { + $qb = $this->createSearchQuery($default, $firstname, $lastname, + $birthdate, $birthdateBefore, $birthdateAfter, $gender, + $countryCode); + $this->addACLClauses($qb, 'p'); + + return $this->getQueryResult($qb, 'p', $simplify, $limit, $start); + } + + /** + * Helper method to prepare and return the search query for PersonACL. + * + * This method replace the select clause with required parameters, depending on the + * "simplify" parameter. It also add query limits. + * + * The given alias must represent the person alias. + * + * @return array|Person[] + */ + public function getQueryResult(QueryBuilder $qb, string $alias, bool $simplify, int $limit, int $start): array + { + if ($simplify) { + $qb->select( + $alias.'.id', + $qb->expr()->concat( + $alias.'.firstName', + $qb->expr()->literal(' '), + $alias.'.lastName' + ).'AS text' + ); + } else { + $qb->select($alias); + } + + $qb + ->setMaxResults($limit) + ->setFirstResult($start); + + //order by firstname, lastname + $qb + ->orderBy($alias.'.firstName') + ->addOrderBy($alias.'.lastName'); + + if ($simplify) { + return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY); + } else { + return $qb->getQuery()->getResult(); + } + } + + public function countBySearchCriteria( + string $default = null, + string $firstname = null, + string $lastname = null, + ?\DateTime $birthdate = null, + ?\DateTime $birthdateBefore = null, + ?\DateTime $birthdateAfter = null, + string $gender = null, + string $countryCode = null + ): int { + $qb = $this->createSearchQuery($default, $firstname, $lastname, + $birthdate, $birthdateBefore, $birthdateAfter, $gender, + $countryCode); + $this->addACLClauses($qb, 'p'); + + return $this->getCountQueryResult($qb,'p'); + } + + /** + * Helper method to prepare and return the count for search query + * + * This method replace the select clause with required parameters, depending on the + * "simplify" parameter. + * + * The given alias must represent the person alias in the query builder. + */ + public function getCountQueryResult(QueryBuilder $qb, $alias): int + { + $qb->select('COUNT('.$alias.'.id)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function findBySimilaritySearch(string $pattern, int $firstResult, + int $maxResult, bool $simplify = false) + { + $qb = $this->createSimilarityQuery($pattern); + $this->addACLClauses($qb, 'sp'); + + return $this->getQueryResult($qb, 'sp', $simplify, $maxResult, $firstResult); + } + + public function countBySimilaritySearch(string $pattern) + { + $qb = $this->createSimilarityQuery($pattern); + $this->addACLClauses($qb, 'sp'); + + return $this->getCountQueryResult($qb, 'sp'); + } + + /** + * Create a search query without ACL + * + * The person alias is a "p" + * + * @param string|null $default + * @param string|null $firstname + * @param string|null $lastname + * @param \DateTime|null $birthdate + * @param \DateTime|null $birthdateBefore + * @param \DateTime|null $birthdateAfter + * @param string|null $gender + * @param string|null $countryCode + * @return QueryBuilder + * @throws NonUniqueResultException + * @throws ParsingException + */ + public function createSearchQuery( + string $default = null, + string $firstname = null, + string $lastname = null, + ?\DateTime $birthdate = null, + ?\DateTime $birthdateBefore = null, + ?\DateTime $birthdateAfter = null, + string $gender = null, + string $countryCode = null + ): QueryBuilder { + + if (!$this->security->getUser() instanceof User) { + throw new \RuntimeException("Search must be performed by a valid user"); + } + $qb = $this->em->createQueryBuilder(); + $qb->from(Person::class, 'p'); + + if (NULL !== $firstname) { + $qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname')) + ->setParameter('firstname', '%'.$firstname.'%'); + } + + if (NULL !== $lastname) { + $qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname')) + ->setParameter('lastname', '%'.$lastname.'%'); + } + + if (NULL !== $birthdate) { + $qb->andWhere($qb->expr()->eq('s.birthdate', ':birthdate')) + ->setParameter('birthdate', $birthdate); + } + + if (NULL !== $birthdateAfter) { + $qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter')) + ->setParameter('birthdateafter', $birthdateAfter); + } + + if (NULL !== $birthdateBefore) { + $qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore')) + ->setParameter('birthdatebefore', $birthdateBefore); + } + + if (NULL !== $gender) { + $qb->andWhere($qb->expr()->eq('p.gender', ':gender')) + ->setParameter('gender', $gender); + } + + if (NULL !== $countryCode) { + try { + $country = $this->countryRepository->findOneBy(['countryCode' => $countryCode]); + } catch (NoResultException $ex) { + throw new ParsingException('The country code "'.$countryCode.'" ' + . ', used in nationality, is unknow', 0, $ex); + } catch (NonUniqueResultException $e) { + throw $e; + } + + $qb->andWhere($qb->expr()->eq('p.nationality', ':nationality')) + ->setParameter('nationality', $country); + } + + if (NULL !== $default) { + $grams = explode(' ', $default); + + foreach($grams as $key => $gram) { + $qb->andWhere($qb->expr() + ->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))')) + ->setParameter('default_'.$key, '%'.$gram.'%'); + } + } + + return $qb; + } + + private function addACLClauses(QueryBuilder $qb, string $personAlias): void + { + // restrict center for security + $reachableCenters = $this->authorizationHelper + ->getReachableCenters($this->security->getUser(), 'CHILL_PERSON_SEE'); + $qb->andWhere( + $qb->expr()->orX( + $qb->expr() + ->in($personAlias.'.center', ':centers'), + $qb->expr() + ->isNull($personAlias.'.center') + ) + ); + $qb->setParameter('centers', $reachableCenters); + } + + /** + * 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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php new file mode 100644 index 000000000..8e83da03d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php @@ -0,0 +1,50 @@ +repository->findBy(['id' => $ids]); } + public function findAll() + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria) + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName() + { + return Person::class; + } + /** * @param $centers * @param $firstResult diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue index e621feead..797f246fe 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue @@ -10,6 +10,7 @@ + @@ -32,6 +33,7 @@ import PersonsAssociated from './components/PersonsAssociated.vue'; import Requestor from './components/Requestor.vue'; import SocialIssue from './components/SocialIssue.vue'; import CourseLocation from './components/CourseLocation.vue'; +import Scopes from './components/Scopes.vue'; import Referrer from './components/Referrer.vue'; import Resources from './components/Resources.vue'; import Comment from './components/Comment.vue'; @@ -47,6 +49,7 @@ export default { Requestor, SocialIssue, CourseLocation, + Scopes, Referrer, Resources, Comment, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js index 52e253ac1..ee4c3e11b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js @@ -191,7 +191,49 @@ const getListOrigins = () => { if (response.ok) { return response.json(); } throw { msg: 'Error while retriving origin\'s list.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; }); -} +}; + +const addScope = (id, scope) => { + const url = `/api/1.0/person/accompanying-course/${id}/scope.json`; + console.log(url); + console.log(scope); + + return fetch(url, { + method: 'POST', + body: JSON.stringify({ + id: scope.id, + type: scope.type, + }), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }) + .then(response => { + if (response.ok) { return response.json(); } + throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; + }); +}; + +const removeScope = (id, scope) => { + const url = `/api/1.0/person/accompanying-course/${id}/scope.json`; + console.log(url); + console.log(scope); + + return fetch(url, { + method: 'DELETE', + body: JSON.stringify({ + id: scope.id, + type: scope.type, + }), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }) + .then(response => { + if (response.ok) { return response.json(); } + throw { msg: 'Error while adding scope', sta: response.status, txt: response.statusText, err: new Error(), body: response.body }; + }); +}; export { getAccompanyingCourse, @@ -204,5 +246,7 @@ export { getUsers, whoami, getListOrigins, - postSocialIssue + postSocialIssue, + addScope, + removeScope, }; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue index 44479831c..b7834039d 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue @@ -88,6 +88,10 @@ export default { socialIssue: { msg: 'confirm.socialIssue_not_valid', anchor: '#section-50' + }, + scopes: { + msg: 'confirm.set_a_scope', + anchor: '#section-65' } } } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue new file mode 100644 index 000000000..ca1b36770 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js index d7c01a174..cf3a0fb7e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js @@ -86,6 +86,10 @@ const appMessages = { person_locator: "Parcours localisé auprès de {0}", no_address: "Il n'y a pas d'adresse associée au parcours" }, + scopes: { + title: "Services", + add_at_least_one: "Indiquez au moins un service", + }, referrer: { title: "Référent du parcours", label: "Vous pouvez choisir un TMS ou vous assigner directement comme référent", @@ -113,6 +117,7 @@ const appMessages = { participation_not_valid: "sélectionnez au minimum 1 usager", socialIssue_not_valid: "sélectionnez au minimum une problématique sociale", location_not_valid: "indiquez au minimum une localisation temporaire du parcours", + set_a_scope: "indiquez au moins un service", sure: "Êtes-vous sûr ?", sure_description: "Une fois le changement confirmé, il ne sera plus possible de le remettre à l'état de brouillon !", ok: "Confirmer le parcours" diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js index 34a6786fc..4deb86e40 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js @@ -1,28 +1,41 @@ import 'es6-promise/auto'; import { createStore } from 'vuex'; +import { fetchScopes } from 'ChillMainAssets/lib/api/scope.js'; import { getAccompanyingCourse, patchAccompanyingCourse, confirmAccompanyingCourse, postParticipation, postRequestor, postResource, - postSocialIssue } from '../api'; + postSocialIssue, + addScope, + removeScope, +} from '../api'; const debug = process.env.NODE_ENV !== 'production'; const id = window.accompanyingCourseId; -let initPromise = getAccompanyingCourse(id) - .then(accompanying_course => new Promise((resolve, reject) => { +let scopesPromise = fetchScopes(); +let accompanyingCoursePromise = getAccompanyingCourse(id); + +let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) + .then(([scopes, accompanyingCourse]) => new Promise((resolve, reject) => { const store = createStore({ strict: debug, modules: { }, state: { - accompanyingCourse: accompanying_course, + accompanyingCourse: accompanyingCourse, addressContext: {}, - errorMsg: [] + errorMsg: [], + // all the available scopes + scopes: scopes, + // the scopes at start. If the user remove all scopes, we re-add those scopes, by security + scopesAtStart: accompanyingCourse.scopes.map(scope => scope), + // the scope states at server side + scopesAtBackend: accompanyingCourse.scopes.map(scope => scope), }, getters: { isParticipationValid(state) { @@ -34,11 +47,16 @@ let initPromise = getAccompanyingCourse(id) isLocationValid(state) { return state.accompanyingCourse.location !== null; }, + isScopeValid(state) { + console.log('is scope valid', state.accompanyingCourse.scopes.length > 0); + return state.accompanyingCourse.scopes.length > 0; + }, validationKeys(state, getters) { let keys = []; if (!getters.isParticipationValid) { keys.push('participation'); } if (!getters.isLocationValid) { keys.push('location'); } if (!getters.isSocialIssueValid) { keys.push('socialIssue'); } + if (!getters.isScopeValid) { keys.push('scopes'); } //console.log('getter keys', keys); return keys; }, @@ -137,6 +155,21 @@ let initPromise = getAccompanyingCourse(id) setEditContextTrue(state) { //console.log('### mutation: set edit context = true'); state.addressContext.edit = true; + }, + setScopes(state, scopes) { + state.accompanyingCourse.scopes = scopes; + }, + addScopeAtBackend(state, scope) { + let scopeIds = state.scopesAtBackend.map(s => s.id); + if (!scopeIds.includes(scope.id)) { + state.scopesAtBackend.push(scope); + } + }, + removeScopeAtBackend(state, scope){ + let scopeIds = state.scopesAtBackend.map(s => s.id); + if (scopeIds.includes(scope.id)) { + state.scopesAtBackend = state.scopesAtBackend.filter(s => s.id !== scope.id); + } } }, actions: { @@ -223,6 +256,107 @@ let initPromise = getAccompanyingCourse(id) resolve(); })).catch((error) => { commit('catchError', error) }); }, + /** + * Handle the checked/unchecked scopes + * + * When the user set the scopes in a invalid situation (when no scopes are cheched), this + * method will internally re-add the scopes as they were originally when the page was loaded, but + * this does not appears for the user (they remains unchecked). When the user re-add a scope, the + * scope is back in a valid state, and the store synchronize with the new state (all the original scopes + * are removed if necessary, and the new checked scopes is backed). + * + * So, for instance: + * + * at load: + * + * [x] scope A (at backend: [x]) + * [x] scope B (at backend: [x]) + * [ ] scope C (at backend: [ ]) + * + * The user uncheck scope A: + * + * [ ] scope A (at backend: [ ] as soon as the operation finish) + * [x] scope B (at backend: [x]) + * [ ] scope C (at backend: [ ]) + * + * The user uncheck scope B. The state is invalid (no scope checked), so we go back to initial state when + * the page loaded): + * + * [ ] scope A (at backend: [x] as soon as the operation finish) + * [ ] scope B (at backend: [x] as soon as the operation finish) + * [ ] scope C (at backend: [ ]) + * + * The user check scope C. The scopes are back to valid state. So we go back to synchronization with UI and + * backend): + * + * [ ] scope A (at backend: [ ] as soon as the operation finish) + * [ ] scope B (at backend: [ ] as soon as the operation finish) + * [x] scope C (at backend: [x] as soon as the operation finish) + * + * **Warning** There is a problem if the user check/uncheck faster than the backend is synchronized. + * + * @param commit + * @param state + * @param dispatch + * @param scopes + * @returns Promise + */ + setScopes({ commit, state, dispatch }, scopes) { + let currentServerScopesIds = state.scopesAtBackend.map(scope => scope.id); + let checkedScopesIds = scopes.map(scope => scope.id); + let removedScopesIds = currentServerScopesIds.filter(id => !checkedScopesIds.includes(id)); + let addedScopesIds = checkedScopesIds.filter(id => !currentServerScopesIds.includes(id)); + let lengthAfterOperation = currentServerScopesIds.length + addedScopesIds.length + - removedScopesIds.length; + + if (lengthAfterOperation > 0 || (lengthAfterOperation === 0 && state.scopesAtStart.length === 0) ) { + return dispatch('updateScopes', { + addedScopesIds, removedScopesIds + }).then(() => { + // warning: when the operation of dispatch are too slow, the user may check / uncheck before + // the end of the synchronisation with the server (done by dispatch operation). Then, it leads to + // check/uncheck in the UI. I do not know of to avoid it. + commit('setScopes', scopes); + return Promise.resolve(); + }); + } else { + return dispatch('setScopes', state.scopesAtStart).then(() => { + commit('setScopes', scopes); + return Promise.resolve(); + }); + } + }, + /** + * Internal function for the store to effectively update scopes. + * + * Return a promise which resolves when all update operation are + * successful and finished. + * + * @param state + * @param commit + * @param addedScopesIds + * @param removedScopesIds + * @return Promise + */ + updateScopes({ state, commit }, { addedScopesIds, removedScopesIds }) { + let promises = []; + state.scopes.forEach(scope => { + if (addedScopesIds.includes(scope.id)) { + promises.push(addScope(state.accompanyingCourse.id, scope).then(() => { + commit('addScopeAtBackend', scope); + return Promise.resolve(); + })); + } + if (removedScopesIds.includes(scope.id)) { + promises.push(removeScope(state.accompanyingCourse.id, scope).then(() => { + commit('removeScopeAtBackend', scope); + return Promise.resolve(); + })); + } + }); + + return Promise.all(promises); + }, postFirstComment({ commit }, payload) { //console.log('## action: postFirstComment: payload', payload); patchAccompanyingCourse(id, { type: "accompanying_period", initialComment: payload }) diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig index f60b7dc9e..612a5609c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig @@ -146,10 +146,10 @@ {% endif %} {% endif %} - {% if options['addCenter'] %} + {% if options['addCenter'] and person|chill_resolve_center is not null %}
  • - {{ person.center }} + {{ person|chill_resolve_center.name }}
  • {% endif %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/banner_custom.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/banner_custom.html.twig index d01a53c22..564f1803e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/banner_custom.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/banner_custom.html.twig @@ -17,11 +17,11 @@ {%- endif -%}
    - {%- if chill_person.fields.spoken_languages == 'visible' -%} + {% if person|chill_resolve_center is not null%} {{ 'Center'|trans|upper}} : - {{ person.center.name|upper }} + {{ person|chill_resolve_center.name|upper }} {%- endif -%}
    diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig index 7056f25a3..bd1042dd7 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig @@ -76,10 +76,9 @@ {{ form_row(form.gender, { 'label' : 'Gender'|trans }) }} -
    - {# TODO remove this field (vendee) #} - {{ form_row(form.center, { 'label' : 'Center'|trans }) }} -
    + {% if form.center is defined %} + {{ form_row(form.center) }} + {% endif %}