Merge branch 'master' into person_renderbox_thirdparty_onthefly

This commit is contained in:
2021-09-23 13:56:28 +02:00
129 changed files with 4150 additions and 1615 deletions

View File

@@ -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
]);

View File

@@ -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
]);

View File

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

View File

@@ -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([

View File

@@ -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();
}

View File

@@ -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
]
]
]);
}
/**

View File

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

View File

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

View File

@@ -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;

View File

@@ -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();

View File

@@ -0,0 +1,82 @@
<?php
namespace Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Entity\Address;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
/**
* Entity which associate person with his current address, through
* household participation.
*
* The computation is optimized on database side.
*
* The validFrom and validTo properties are the intersection of
* household membership and address validity. See @link{PersonHouseholdAddress}
*
* @ORM\Entity(readOnly=true)
* @ORM\Table("view_chill_person_current_address")
*/
class PersonCurrentAddress
{
/**
* @ORM\Id
* @ORM\OneToOne(targetEntity=Person::class, inversedBy="currentPersonAddress")
* @ORM\JoinColumn(name="person_id", referencedColumnName="id")
*/
protected Person $person;
/**
* @ORM\OneToOne(targetEntity=Address::class)
*/
protected Address $address;
/**
* @ORM\Column(name="valid_from", type="date_immutable")
*/
protected \DateTimeImmutable $validFrom;
/**
* @ORM\Column(name="valid_to", type="date_immutable")
*/
protected ?\DateTimeImmutable $validTo;
/**
* @return Person
*/
public function getPerson(): Person
{
return $this->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;
}
}

View File

@@ -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
);
}
/**

View File

@@ -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))
));
}

View File

@@ -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' => [

View File

@@ -0,0 +1,76 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Security\Core\Security;
final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface
{
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private Security $security;
private AuthorizationHelper $authorizationHelper;
private CenterResolverDispatcher $centerResolverDispatcher;
public function __construct(AccompanyingPeriodRepository $accompanyingPeriodRepository, Security $security, AuthorizationHelper $authorizationHelper, CenterResolverDispatcher $centerResolverDispatcher)
{
$this->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();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\PersonBundle\Entity\Person;
interface AccompanyingPeriodACLAwareRepositoryInterface
{
public function findByPerson(
Person $person,
string $role,
?array $orderBy = [],
int $limit = null,
int $offset = null
): array;
}

View File

@@ -59,6 +59,11 @@ final class AccompanyingPeriodRepository implements ObjectRepository
return $this->findOneBy($criteria);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function getClassName()
{
return AccompanyingPeriod::class;

View File

@@ -0,0 +1,292 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
{
private Security $security;
private EntityManagerInterface $em;
private CountryRepository $countryRepository;
private AuthorizationHelper $authorizationHelper;
public function __construct(
Security $security,
EntityManagerInterface $em,
CountryRepository $countryRepository,
AuthorizationHelper $authorizationHelper
) {
$this->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;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Search\ParsingException;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\NonUniqueResultException;
interface PersonACLAwareRepositoryInterface
{
/**
* @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;
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
);
public function findBySimilaritySearch(
string $pattern,
int $firstResult,
int $maxResult,
bool $simplify = false
);
public function countBySimilaritySearch(string $pattern);
}

View File

@@ -23,9 +23,11 @@ use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use UnexpectedValueException;
final class PersonRepository
final class PersonRepository implements ObjectRepository
{
private EntityRepository $repository;
@@ -44,6 +46,26 @@ final class PersonRepository
return $this->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

View File

@@ -10,6 +10,7 @@
<origin-demand></origin-demand>
<requestor></requestor>
<social-issue></social-issue>
<scopes></scopes>
<referrer></referrer>
<resources></resources>
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
@@ -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,

View File

@@ -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,
};

View File

@@ -88,6 +88,10 @@ export default {
socialIssue: {
msg: 'confirm.socialIssue_not_valid',
anchor: '#section-50'
},
scopes: {
msg: 'confirm.set_a_scope',
anchor: '#section-65'
}
}
}

View File

@@ -0,0 +1,47 @@
<template>
<div class="vue-component">
<h2><a name="section-65"></a>{{ $t('scopes.title') }}</h2>
<ul>
<li v-for="s in scopes">
<input type="checkbox" v-model="checkedScopes" :value="s" />
{{ s.name.fr }}
</li>
</ul>
<div v-if="!isScopeValid" class="alert alert-warning separator">
{{ $t('scopes.add_at_least_one') }}
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
export default {
name: "Scopes",
computed: {
...mapState([
'scopes',
'scopesAtStart'
]),
...mapGetters([
'isScopeValid'
]),
checkedScopes: {
get: function() {
return this.$store.state.accompanyingCourse.scopes;
},
set: function(v) {
this.$store.dispatch('setScopes', v);
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -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"

View File

@@ -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;
},
@@ -142,6 +160,21 @@ let initPromise = getAccompanyingCourse(id)
//console.log('### mutation: set edit context true with addressId', payload.addressId);
state.addressContext.edit = true;
state.addressContext.addressId = payload.addressId;
},
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: {
@@ -228,6 +261,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 })

View File

@@ -146,10 +146,10 @@
{% endif %}
{% endif %}
</li>
{% if options['addCenter'] %}
{% if options['addCenter'] and person|chill_resolve_center is not null %}
<li>
<i class="fa fa-li fa-long-arrow-right"></i>
{{ person.center }}
{{ person|chill_resolve_center.name }}
</li>
{% endif %}
</ul>

View File

@@ -17,11 +17,11 @@
{%- endif -%}
</div>
<div class="text-md-end">
{%- if chill_person.fields.spoken_languages == 'visible' -%}
{% if person|chill_resolve_center is not null%}
<span class="open_sansbold">
{{ 'Center'|trans|upper}} :
</span>
{{ person.center.name|upper }}
{{ person|chill_resolve_center.name|upper }}
{%- endif -%}
</div>
</div>

View File

@@ -76,10 +76,9 @@
{{ form_row(form.gender, { 'label' : 'Gender'|trans }) }}
<div style="display: none">
{# TODO remove this field (vendee) #}
{{ form_row(form.center, { 'label' : 'Center'|trans }) }}
</div>
{% if form.center is defined %}
{{ form_row(form.center) }}
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li class="dropdown">

View File

@@ -20,71 +20,39 @@
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\AbstractSearch;
use Doctrine\ORM\EntityManagerInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Search\SearchInterface;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Chill\MainBundle\Search\ParsingException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Symfony\Component\Security\Core\Role\Role;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Chill\MainBundle\Search\HasAdvancedSearchFormInterface;
use Doctrine\ORM\Query;
use Symfony\Component\Templating\EngineInterface;
class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
HasAdvancedSearchFormInterface
class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface
{
use ContainerAwareTrait;
/**
*
* @var EntityManagerInterface
*/
private $em;
/**
*
* @var \Chill\MainBundle\Entity\User
*/
private $user;
/**
*
* @var AuthorizationHelper
*/
private $helper;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
protected EngineInterface $templating;
protected PaginatorFactory $paginatorFactory;
protected PersonACLAwareRepositoryInterface $personACLAwareRepository;
const NAME = "person_regular";
private const POSSIBLE_KEYS = [
'_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before',
'birthdate-after', 'gender', 'nationality'
];
public function __construct(
EntityManagerInterface $em,
TokenStorageInterface $tokenStorage,
AuthorizationHelper $helper,
PaginatorFactory $paginatorFactory)
{
$this->em = $em;
$this->user = $tokenStorage->getToken()->getUser();
$this->helper = $helper;
EngineInterface $templating,
PaginatorFactory $paginatorFactory,
PersonACLAwareRepositoryInterface $personACLAwareRepository
) {
$this->templating = $templating;
$this->paginatorFactory = $paginatorFactory;
// throw an error if user is not a valid user
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
throw new \LogicException('The user provided must be an instance'
. ' of Chill\MainBundle\Entity\User');
}
$this->personACLAwareRepository = $personACLAwareRepository;
}
/*
@@ -120,12 +88,14 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
$paginator = $this->paginatorFactory->create($total);
if ($format === 'html') {
return $this->container->get('templating')->render('@ChillPerson/Person/list_with_period.html.twig',
return $this->templating->render('@ChillPerson/Person/list_with_period.html.twig',
array(
'persons' => $this->search($terms, $start, $limit, $options),
'pattern' => $this->recomposePattern($terms, array('nationality',
'firstname', 'lastname', 'birthdate', 'gender',
'birthdate-before','birthdate-after'), $terms['_domain']),
'pattern' => $this->recomposePattern(
$terms,
\array_filter(self::POSSIBLE_KEYS, fn($item) => $item !== '_default'),
$terms['_domain']
),
'total' => $total,
'start' => $start,
'search_name' => self::NAME,
@@ -152,153 +122,81 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
*/
protected function search(array $terms, $start, $limit, array $options = array())
{
$qb = $this->createQuery($terms, 'search');
if ($options['simplify'] ?? false) {
$qb->select(
'p.id',
$qb->expr()->concat(
'p.firstName',
$qb->expr()->literal(' '),
'p.lastName'
).'AS text'
);
} else {
$qb->select('p');
[
'_default' => $default,
'firstname' => $firstname,
'lastname' => $lastname,
'birthdate' => $birthdate,
'birthdate-before' => $birthdateBefore,
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
if (NULL !== ${$v}) {
try {
${$v} = new \DateTime(${$v});
} catch (\Exception $e) {
throw new ParsingException('The date is '
. 'not parsable', 0, $e);
}
}
}
$qb
->setMaxResults($limit)
->setFirstResult($start);
//order by firstname, lastname
$qb
->orderBy('p.firstName')
->addOrderBy('p.lastName');
if ($options['simplify'] ?? false) {
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
} else {
return $qb->getQuery()->getResult();
}
return $this->personACLAwareRepository
->findBySearchCriteria(
$start,
$limit,
$options['simplify'] ?? false,
$default,
$firstname,
$lastname,
$birthdate,
$birthdateBefore,
$birthdateAfter,
$gender,
$countryCode,
);
}
protected function count(array $terms)
protected function count(array $terms): int
{
$qb = $this->createQuery($terms);
[
'_default' => $default,
'firstname' => $firstname,
'lastname' => $lastname,
'birthdate' => $birthdate,
'birthdate-before' => $birthdateBefore,
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
$qb->select('COUNT(p.id)');
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
return $qb->getQuery()->getSingleScalarResult();
}
private $_cacheQuery = array();
/**
*
* @param array $terms
* @return \Doctrine\ORM\QueryBuilder
*/
public function createQuery(array $terms)
{
//get from cache
$cacheKey = md5(serialize($terms));
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
return clone $this->_cacheQuery[$cacheKey];
}
$qb = $this->em->createQueryBuilder();
$qb->from('ChillPersonBundle:Person', 'p');
if (array_key_exists('firstname', $terms)) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname'))
->setParameter('firstname', '%'.$terms['firstname'].'%');
}
if (array_key_exists('lastname', $terms)) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname'))
->setParameter('lastname', '%'.$terms['lastname'].'%');
}
foreach (['birthdate', 'birthdate-before', 'birthdate-after'] as $key)
if (array_key_exists($key, $terms)) {
try {
$date = new \DateTime($terms[$key]);
} catch (\Exception $ex) {
throw new ParsingException('The date is '
. 'not parsable', 0, $ex);
}
switch($key) {
case 'birthdate':
$qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate'))
->setParameter('birthdate', $date);
break;
case 'birthdate-before':
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
->setParameter('birthdatebefore', $date);
break;
case 'birthdate-after':
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
->setParameter('birthdateafter', $date);
break;
default:
throw new \LogicException("this case $key should not exists");
}
}
if (array_key_exists('gender', $terms)) {
if (!in_array($terms['gender'], array(Person::MALE_GENDER, Person::FEMALE_GENDER))) {
throw new ParsingException('The gender '
.$terms['gender'].' is not accepted. Should be "'.Person::MALE_GENDER
.'" or "'.Person::FEMALE_GENDER.'"');
}
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
->setParameter('gender', $terms['gender']);
}
if (array_key_exists('nationality', $terms)) {
try {
$country = $this->em->createQuery('SELECT c FROM '
. 'ChillMainBundle:Country c WHERE '
. 'LOWER(c.countryCode) LIKE :code')
->setParameter('code', $terms['nationality'])
->getSingleResult();
} catch (\Doctrine\ORM\NoResultException $ex) {
throw new ParsingException('The country code "'.$terms['nationality'].'" '
. ', used in nationality, is unknow', 0, $ex);
}
$qb->andWhere($qb->expr()->eq('p.nationality', ':nationality'))
->setParameter('nationality', $country);
}
if ($terms['_default'] !== '') {
$grams = explode(' ', $terms['_default']);
foreach($grams as $key => $gram) {
$qb->andWhere($qb->expr()
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
->setParameter('default_'.$key, '%'.$gram.'%');
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
if (NULL !== ${$v}) {
try {
${$v} = new \DateTime(${$v});
} catch (\Exception $e) {
throw new ParsingException('The date is '
. 'not parsable', 0, $e);
}
}
}
//restraint center for security
$reachableCenters = $this->helper->getReachableCenters($this->user,
new Role('CHILL_PERSON_SEE'));
$qb->andWhere($qb->expr()
->in('p.center', ':centers'))
->setParameter('centers', $reachableCenters)
;
$this->_cacheQuery[$cacheKey] = $qb;
return clone $qb;
return $this->personACLAwareRepository
->countBySearchCriteria(
$default,
$firstname,
$lastname,
$birthdate,
$birthdateBefore,
$birthdateAfter,
$gender,
$countryCode,
);
}
public function buildForm(FormBuilderInterface $builder)
@@ -391,4 +289,10 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
return 'Search within persons';
}
public static function getAlias(): string
{
return self::NAME;
}
}

View File

@@ -2,90 +2,32 @@
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\AbstractSearch;
use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Role\Role;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Symfony\Component\Templating\EngineInterface;
/**
* Class SimilarityPersonSearch
*
* @package Chill\PersonBundle\Search
*/
class SimilarityPersonSearch extends AbstractSearch
{
use ContainerAwareTrait;
/**
*
* @var EntityManagerInterface
*/
private $em;
/**
*
* @var \Chill\MainBundle\Entity\User
*/
private $user;
/**
*
* @var AuthorizationHelper
*/
private $helper;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
protected PaginatorFactory $paginatorFactory;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private EngineInterface $templating;
const NAME = "person_similarity";
/**
*
* @var PersonSearch
*/
private $personSearch;
/**
* SimilarityPersonSearch constructor.
*
* @param EntityManagerInterface $em
* @param TokenStorageInterface $tokenStorage
* @param AuthorizationHelper $helper
* @param PaginatorFactory $paginatorFactory
* @param PersonSearch $personSearch
*/
public function __construct(
EntityManagerInterface $em,
TokenStorageInterface $tokenStorage,
AuthorizationHelper $helper,
PaginatorFactory $paginatorFactory,
PersonSearch $personSearch)
{
$this->em = $em;
$this->user = $tokenStorage->getToken()->getUser();
$this->helper = $helper;
PersonACLAwareRepositoryInterface $personACLAwareRepository,
EngineInterface $templating
) {
$this->paginatorFactory = $paginatorFactory;
$this->personSearch = $personSearch;
// throw an error if user is not a valid user
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
throw new \LogicException('The user provided must be an instance'
. ' of Chill\MainBundle\Entity\User');
}
$this->personACLAwareRepository = $personACLAwareRepository;
$this->templating = $templating;
}
/*
* (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::getOrder()
@@ -94,7 +36,7 @@ class SimilarityPersonSearch extends AbstractSearch
{
return 200;
}
/*
* (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault()
@@ -103,30 +45,27 @@ class SimilarityPersonSearch extends AbstractSearch
{
return true;
}
public function supports($domain, $format)
{
return 'person' === $domain;
}
/**
* @param array $terms
* @param int $start
* @param int $limit
* @param array $options
* @param string $format
* @return array
*/
public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
{
$total = $this->count($terms);
$paginator = $this->paginatorFactory->create($total);
if ($format === 'html')
{
if ($total !== 0)
{
return $this->container->get('templating')->render('ChillPersonBundle:Person:list.html.twig',
if ($format === 'html') {
if ($total !== 0) {
return $this->templating->render('ChillPersonBundle:Person:list.html.twig',
array(
'persons' => $this->search($terms, $start, $limit, $options),
'pattern' => $this->recomposePattern($terms, array('nationality',
@@ -143,9 +82,8 @@ class SimilarityPersonSearch extends AbstractSearch
else {
return null;
}
} elseif ($format === 'json')
{
} elseif ($format === 'json') {
return [
'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])),
'pagination' => [
@@ -154,8 +92,7 @@ class SimilarityPersonSearch extends AbstractSearch
];
}
}
/**
*
* @param string $pattern
@@ -166,101 +103,12 @@ class SimilarityPersonSearch extends AbstractSearch
*/
protected function search(array $terms, $start, $limit, array $options = array())
{
$qb = $this->createQuery($terms, 'search');
if ($options['simplify'] ?? false) {
$qb->select(
'sp.id',
$qb->expr()->concat(
'sp.firstName',
$qb->expr()->literal(' '),
'sp.lastName'
).'AS text'
);
} else {
$qb->select('sp');
}
$qb
->setMaxResults($limit)
->setFirstResult($start);
//order by firstname, lastname
$qb
->orderBy('sp.firstName')
->addOrderBy('sp.lastName');
if ($options['simplify'] ?? false) {
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
} else {
return $qb->getQuery()->getResult();
}
return $this->personACLAwareRepository
->findBySimilaritySearch($terms['_default'], $start, $limit, $options['simplify'] ?? false);
}
protected function count(array $terms)
{
$qb = $this->createQuery($terms);
$qb->select('COUNT(sp.id)');
return $qb->getQuery()->getSingleScalarResult();
return $this->personACLAwareRepository->countBySimilaritySearch($terms['_default']);
}
private $_cacheQuery = array();
/**
*
* @param array $terms
* @return \Doctrine\ORM\QueryBuilder
*/
protected function createQuery(array $terms)
{
//get from cache
$cacheKey = md5(serialize($terms));
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
return clone $this->_cacheQuery[$cacheKey];
}
$qb = $this->em->createQueryBuilder();
$qb ->select('sp')
->from('ChillPersonBundle:Person', 'sp');
if ($terms['_default'] !== '') {
$grams = explode(' ', $terms['_default']);
foreach($grams as $key => $gram) {
$qb->andWhere('SIMILARITY(sp.fullnameCanonical, UNACCENT(LOWER(:default_'.$key.')) ) >= 0.15')
->setParameter('default_'.$key, '%'.$gram.'%');
}
$qb->andWhere($qb->expr()
->notIn(
'sp.id',
$this->personSearch
->createQuery($terms)
->addSelect('p.id')
->getDQL()
)
);
}
//restraint center for security
$reachableCenters = $this->helper->getReachableCenters($this->user,
new Role('CHILL_PERSON_SEE'));
$qb->andWhere($qb->expr()
->in('sp.center', ':centers'))
->setParameter('centers', $reachableCenters)
;
$this->_cacheQuery[$cacheKey] = $qb;
return clone $qb;
}
}
}

View File

@@ -4,73 +4,101 @@ namespace Chill\PersonBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface;
use Chill\MainBundle\Security\Authorization\VoterHelperInterface;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Entity\Center;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Security;
class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{
protected AuthorizationHelper $helper;
public const SEE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE';
/**
* details are for seeing:
*
* * SocialIssues
*/
public const SEE_DETAILS = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS';
public const CREATE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE';
public const EDIT = 'CHILL_PERSON_ACCOMPANYING_PERIOD_UPDATE';
public const DELETE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_DELETE';
/**
* @param AuthorizationHelper $helper
* Give all the right above
*/
public function __construct(AuthorizationHelper $helper)
{
$this->helper = $helper;
public const FULL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_FULL';
public const ALL = [
self::SEE,
self::SEE_DETAILS,
self::CREATE,
self::EDIT,
self::DELETE,
self::FULL,
];
private VoterHelperInterface $voterHelper;
private Security $security;
public function __construct(
Security $security,
VoterHelperFactoryInterface $voterHelperFactory
) {
$this->security = $security;
$this->voterHelper = $voterHelperFactory
->generate(self::class)
->addCheckFor(null, [self::CREATE])
->addCheckFor(AccompanyingPeriod::class, self::ALL)
->addCheckFor(Person::class, [self::SEE])
->build();
}
protected function supports($attribute, $subject)
{
return $subject instanceof AccompanyingPeriod;
return $this->voterHelper->supports($attribute, $subject);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
if (!$token->getUser() instanceof User) {
return false;
}
// TODO take scopes into account
if (count($subject->getPersons()) === 0) {
return true;
}
foreach ($subject->getPersons() as $person) {
// give access as soon as on center is reachable
if ($this->helper->userHasAccess($token->getUser(), $person->getCenter(), $attribute)) {
return true;
if ($subject instanceof AccompanyingPeriod) {
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
// only creator can see, edit, delete, etc.
if ($subject->getCreatedBy() === $token->getUser()
|| NULL === $subject->getCreatedBy()) {
return true;
}
return false;
}
return false;
// if confidential, only the referent can see it
if ($subject->isConfidential()) {
return $token->getUser() === $subject->getUser();
}
}
}
private function getAttributes()
{
return [
self::SEE
];
return $this->voterHelper->voteOnAttribute($attribute, $subject, $token);
}
public function getRoles()
{
return $this->getAttributes();
return self::ALL;
}
public function getRolesWithoutScope()
{
return [];
}
public function getRolesWithHierarchy()
{
return [ 'Person' => $this->getRoles() ];
return [ 'Accompanying period' => $this->getRoles() ];
}
}

View File

@@ -22,17 +22,15 @@ namespace Chill\PersonBundle\Security\Authorization;
use Chill\MainBundle\Security\Authorization\AbstractChillVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface;
use Chill\MainBundle\Security\Authorization\VoterHelperInterface;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Entity\Center;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Role\Role;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInterface
{
const CREATE = 'CHILL_PERSON_CREATE';
@@ -41,51 +39,31 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte
const STATS = 'CHILL_PERSON_STATS';
const LISTS = 'CHILL_PERSON_LISTS';
const DUPLICATE = 'CHILL_PERSON_DUPLICATE';
/**
*
* @var AuthorizationHelper
*/
protected $helper;
public function __construct(AuthorizationHelper $helper)
{
$this->helper = $helper;
protected VoterHelperInterface $voter;
public function __construct(
VoterHelperFactoryInterface $voterFactory
) {
$this->voter = $voterFactory
->generate(self::class)
->addCheckFor(Center::class, [self::STATS, self::LISTS, self::DUPLICATE])
->addCheckFor(Person::class, [self::CREATE, self::UPDATE, self::SEE, self::DUPLICATE])
->addCheckFor(null, [self::CREATE] )
->build()
;
}
protected function supports($attribute, $subject)
{
if ($subject instanceof Person) {
return \in_array($attribute, [
self::CREATE, self::UPDATE, self::SEE, self::DUPLICATE
]);
} elseif ($subject instanceof Center) {
return \in_array($attribute, [
self::STATS, self::LISTS, self::DUPLICATE
]);
} elseif ($subject === null) {
return $attribute === self::CREATE;
} else {
return false;
}
return $this->voter->supports($attribute, $subject);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
if (!$token->getUser() instanceof User) {
return false;
}
if ($subject === null) {
$centers = $this->helper->getReachableCenters($token->getUser(),
new Role($attribute));
return count($centers) > 0;
}
return $this->helper->userHasAccess($token->getUser(), $subject, $attribute);
return $this->voter->voteOnAttribute($attribute, $subject, $token);
}
private function getAttributes()
{
return array(self::CREATE, self::UPDATE, self::SEE, self::STATS, self::LISTS, self::DUPLICATE);
@@ -100,7 +78,7 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte
{
return $this->getAttributes();
}
public function getRolesWithHierarchy()
{
return [ 'Person' => $this->getRoles() ];

View File

@@ -23,6 +23,7 @@
namespace Chill\PersonBundle\Tests\Controller;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
@@ -47,6 +48,8 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
protected ?AccompanyingPeriod $period = NULL;
protected ?int $periodId = NULL;
/**
* Setup before the first test of this class (see phpunit doc)
*/
@@ -70,15 +73,15 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
*
* @dataProvider dataGenerateRandomAccompanyingCourse
*/
public function testAccompanyingCourseShow(int $personId, AccompanyingPeriod $period)
public function testAccompanyingCourseShow(int $personId, int $periodId)
{
$c = $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId()));
$c = $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $periodId));
$response = $this->client->getResponse();
$this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)");
$data = \json_decode($response->getContent());
$this->assertEquals($data->id, $period->getId(),
$this->assertEquals($data->id, $periodId,
"test that the response's data contains the id of the period"
);
$this->assertGreaterThan(0, $data->participations);
@@ -326,14 +329,16 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
*
* @dataProvider dataGenerateRandomAccompanyingCourse
*/
public function testAccompanyingPeriodPatch(int $personId, AccompanyingPeriod $period)
public function testAccompanyingPeriodPatch(int $personId, int $periodId)
{
$period = self::$container->get(AccompanyingPeriodRepository::class)
->find($periodId);
$initialValueEmergency = $period->isEmergency();
$em = self::$container->get(EntityManagerInterface::class);
$this->client->request(
Request::METHOD_PATCH,
sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId()),
sprintf('/api/1.0/person/accompanying-course/%d.json', $periodId),
[], // parameters
[], // files
[], // server parameters
@@ -343,11 +348,10 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
$this->assertEquals(200, $response->getStatusCode());
$period = $em->getRepository(AccompanyingPeriod::class)
->find($period->getId());
$em->refresh($period);
->find($periodId);
$this->assertEquals(!$initialValueEmergency, $period->isEmergency());
// restore the initial valud
// restore the initial value
$period->setEmergency($initialValueEmergency);
$em->flush();
}
@@ -361,12 +365,32 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
$em = static::$container->get(EntityManagerInterface::class);
$center = $em->getRepository(Center::class)
->findOneBy(array('name' => 'Center A'));
$qb = $em->createQueryBuilder();
$personIds = $em->createQuery("SELECT p.id FROM ".
Person::class." p ".
" WHERE p.center = :center")
$personIds = $qb
->select('p.id')
->distinct(true)
->from(Person::class, 'p')
->join('p.accompanyingPeriodParticipations', 'participation')
->join('participation.accompanyingPeriod', 'ap')
->where(
$qb->expr()->eq(
'p.center',
':center'
)
)
->andWhere(
$qb->expr()->gt(
'SIZE(p.accompanyingPeriodParticipations)',
0)
)
->andWhere(
$qb->expr()->eq('ap.step', ':step')
)
->setParameter('center', $center)
->setParameter('step', AccompanyingPeriod::STEP_CONFIRMED)
->setMaxResults($maxResults)
->getQuery()
->getScalarResult();
// create a random order
@@ -400,11 +424,11 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
*
* @dataProvider dataGenerateRandomAccompanyingCourse
*/
public function testAccompanyingCourseAddParticipation(int $personId, AccompanyingPeriod $period)
public function testAccompanyingCourseAddParticipation(int $personId, int $periodId)
{
$this->client->request(
Request::METHOD_POST,
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()),
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $periodId),
[], // parameters
[], // files
[], // server parameters
@@ -420,7 +444,8 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
// check by deownloading the accompanying cours
$this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId()));
$this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $periodId));
$this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)");
$response = $this->client->getResponse();
$data = \json_decode($response->getContent());
@@ -435,7 +460,7 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
// check removing the participation
$this->client->request(
Request::METHOD_DELETE,
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()),
sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $periodId),
[], // parameters
[], // files
[], // server parameters
@@ -450,42 +475,6 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
$this->assertNotNull($data['startDate']);
$this->assertArrayHasKey('endDate', $data);
$this->assertNotNull($data['endDate']);
// set to variable for tear down
$this->personId = $personId;
$this->period = $period;
}
protected function tearDown()
{
$em = static::$container->get(EntityManagerInterface::class);
// remove participation if set
if ($this->personId && $this->period) {
$participation = $em
->getRepository(AccompanyingPeriodParticipation::class)
->findOneBy(['person' => $this->personId, 'accompanyingPeriod' => $this->period])
;
if (NULL !== $participation) {
$em->remove($participation);
$em->flush();
}
$this->personId = NULL;
$this->period = NULL;
} elseif ($this->period) {
$period = $em
->getRepository(AccompanyingPeriod::class)
->find($this->period->getId()) ;
if ($period !== NULL) {
$em->remove($period);
$em->flush();
}
$this->period = null;
}
}
public function dataGenerateRandomAccompanyingCourseWithSocialIssue()
@@ -505,12 +494,32 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
$em = static::$container->get(EntityManagerInterface::class);
$center = $em->getRepository(Center::class)
->findOneBy(array('name' => 'Center A'));
$qb = $em->createQueryBuilder();
$personIds = $em->createQuery("SELECT p.id FROM ".
Person::class." p ".
" WHERE p.center = :center")
$personIds = $qb
->select('p.id')
->distinct(true)
->from(Person::class, 'p')
->join('p.accompanyingPeriodParticipations', 'participation')
->join('participation.accompanyingPeriod', 'ap')
->where(
$qb->expr()->eq(
'p.center',
':center'
)
)
->andWhere(
$qb->expr()->gt(
'SIZE(p.accompanyingPeriodParticipations)',
0)
)
->andWhere(
$qb->expr()->eq('ap.step', ':step')
)
->setParameter('center', $center)
->setParameter('step', AccompanyingPeriod::STEP_CONFIRMED)
->setMaxResults($maxResults)
->getQuery()
->getScalarResult();
// create a random order
@@ -556,7 +565,10 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
$qb = $em->createQueryBuilder();
$personIds = $qb
->select('p.id')
->distinct(true)
->from(Person::class, 'p')
->join('p.accompanyingPeriodParticipations', 'participation')
->join('participation.accompanyingPeriod', 'ap')
->where(
$qb->expr()->eq(
'p.center',
@@ -568,7 +580,11 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
'SIZE(p.accompanyingPeriodParticipations)',
0)
)
->andWhere(
$qb->expr()->eq('ap.step', ':step')
)
->setParameter('center', $center)
->setParameter('step', AccompanyingPeriod::STEP_CONFIRMED)
->setMaxResults($maxResults)
->getQuery()
->getScalarResult();
@@ -584,7 +600,7 @@ class AccompanyingCourseApiControllerTest extends WebTestCase
->find($id);
$periods = $person->getAccompanyingPeriods();
yield [\array_pop($personIds)["id"], $periods[\array_rand($periods)] ];
yield [\array_pop($personIds)["id"], $periods[\array_rand($periods)]->getId() ];
$nbGenerated++;
}

View File

@@ -1,112 +0,0 @@
<?php
/*
* Copyright (C) 2015 Julien Fastré <julien.fastre@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Tests\Validator;
use Chill\PersonBundle\Validator\Constraints\BirthdateValidator;
use Chill\PersonBundle\Validator\Constraints\Birthdate;
use Prophecy\Argument;
use Prophecy\Prophet;
/**
* Test the behaviour of BirthdayValidator
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class BirthdateValidatorTest extends \PHPUnit\Framework\TestCase
{
/**
* A prophecy for \Symfony\Component\Validator\Context\ExecutionContextInterface
*
* Will reveal \Symfony\Component\Validator\Context\ExecutionContextInterface
*/
private $context;
private $prophet;
/**
*
* @var Birthdate
*/
private $constraint;
public function setUp()
{
$this->prophet = new Prophet;
$constraintViolationBuilder = $this->prophet->prophesize();
$constraintViolationBuilder->willImplement('Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface');
$constraintViolationBuilder->setParameter(Argument::any(), Argument::any())
->willReturn($constraintViolationBuilder->reveal());
$constraintViolationBuilder->addViolation()
->willReturn($constraintViolationBuilder->reveal());
$this->context = $this->prophet->prophesize();
$this->context->willImplement('Symfony\Component\Validator\Context\ExecutionContextInterface');
$this->context->buildViolation(Argument::type('string'))
->willReturn($constraintViolationBuilder->reveal());
$this->constraint = new Birthdate();
}
public function testValidBirthDate()
{
$date = new \DateTime('2015-01-01');
$birthdateValidator = new BirthdateValidator();
$birthdateValidator->initialize($this->context->reveal());
$birthdateValidator->validate($date, $this->constraint);
$this->context->buildViolation(Argument::any())->shouldNotHaveBeenCalled();
}
public function testInvalidBirthDate()
{
$date = new \DateTime('tomorrow');
$birthdateValidator = new BirthdateValidator();
$birthdateValidator->initialize($this->context->reveal());
$birthdateValidator->validate($date, $this->constraint);
$this->context->buildViolation(Argument::type('string'))->shouldHaveBeenCalled();
}
public function testInvalidBirthDateWithParameter()
{
$date = (new \DateTime('today'))->sub(new \DateInterval('P1M'));
$birthdateValidator = new BirthdateValidator('P1Y');
$birthdateValidator->initialize($this->context->reveal());
$birthdateValidator->validate($date, $this->constraint);
$this->context->buildViolation(Argument::type('string'))->shouldHaveBeenCalled();
}
public function tearDown()
{
$this->prophet->checkPredictions();
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* Copyright (C) 2015 Julien Fastré <julien.fastre@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Tests\Validator\Person;
use Chill\PersonBundle\Validator\Constraints\Person\BirthdateValidator;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Chill\PersonBundle\Entity\Person;
/**
* Test the behaviour of BirthdayValidator
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class BirthdateValidatorTest extends ConstraintValidatorTestCase
{
public function testValidateTodayInvalid()
{
$bornToday = new \DateTime('now');
$this->validator->validate($bornToday, $this->createConstraint());
$this->buildViolation('msg')
->setParameter('%date%', (new \DateTime('yesterday'))->format('d-m-Y'))
->setCode('3f42fd96-0b2d-11ec-8cf3-0f3b1b1ca1c4')
->assertRaised();
}
public function testValidateYesterdayValid()
{
$bornYesterday = new \DateTime('yesterday');
$this->validator->validate($bornYesterday, $this->createConstraint());
$this->assertNoViolation();
}
public function testTomorrowInvalid()
{
$bornAfter = new \DateTime('+2 days');
$this->validator->validate($bornAfter, $this->createConstraint());
$this->buildViolation('msg')
->setParameter('%date%', (new \DateTime('yesterday'))->format('d-m-Y'))
->setCode('3f42fd96-0b2d-11ec-8cf3-0f3b1b1ca1c4')
->assertRaised();
}
private function createConstraint()
{
return new Birthdate([
'message' => 'msg'
]);
}
protected function createValidator()
{
return new BirthdateValidator('P1D');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Validator\Person;
use Chill\MainBundle\Entity\Center;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter;
use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenterValidator;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class PersonHasCenterValidatorTest extends \Symfony\Component\Validator\Test\ConstraintValidatorTestCase
{
public function testValidateRequired()
{
$constraint = $this->getConstraint();
$personHasCenter = (new Person())->setCenter(new Center());
$personNoCenter = new Person();
$this->validator->validate($personHasCenter, $constraint);
$this->assertNoViolation();
$this->validator->validate($personNoCenter, $constraint);
$this->buildViolation('msg')
->atPath('property.path.center')
->assertRaised();
}
protected function getConstraint()
{
return new PersonHasCenter([
'message' => 'msg'
]);
}
protected function createValidator()
{
$parameterBag = $this->createMock(ParameterBagInterface::class);
$parameterBag
->method('get')
->with($this->equalTo('chill_person'))
->willReturn([
'validation' => [
'center_required' => true
]
])
;
return new PersonHasCenterValidator($parameterBag);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Validator\Person;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class PersonValidationTest extends KernelTestCase
{
private ValidatorInterface $validator;
protected function setUp()
{
self::bootKernel();
$this->validator = self::$container->get(ValidatorInterface::class);
}
public function testFirstnameValidation()
{
$person = (new Person())
->setFirstname(\str_repeat('a', 500));
$errors = $this->validator->validate($person, null, ["creation"]);
foreach ($errors->getIterator() as $error) {
if (Length::TOO_LONG_ERROR === $error->getCode()) {
$this->assertTrue(true,
"error code for firstname too long is present");
return;
}
}
$this->assertTrue(false,
"error code for fistname too long is present");
}
public function testBirthdateInFuture()
{
$person = (new Person())
->setBirthdate(new \Datetime('+2 months'));
$errors = $this->validator->validate($person, null, ["creation"]);
foreach ($errors->getIterator() as $error) {
if (Birthdate::BIRTHDATE_INVALID_CODE === $error->getCode()) {
$this->assertTrue(true,
"error code for birthdate invalid is present");
return;
}
}
$this->assertTrue(false,
"error code for birthdate invalid is present");
}
}

View File

@@ -17,26 +17,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Validator\Constraints;
namespace Chill\PersonBundle\Validator\Constraints\Person;
use Symfony\Component\Validator\Constraint;
/**
* Create a constraint on birth date: the birthdate after today are not allowed.
*
* It is possible to add a delay before today, expressed as described in
*
* It is possible to add a delay before today, expressed as described in
* interval_spec : http://php.net/manual/en/dateinterval.construct.php
* (this interval_spec itself is based on ISO8601 :
* https://en.wikipedia.org/wiki/ISO_8601#Durations)
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
* @Annotation
*/
class Birthdate extends Constraint
{
public const BIRTHDATE_INVALID_CODE = '3f42fd96-0b2d-11ec-8cf3-0f3b1b1ca1c4';
public $message = "The birthdate must be before %date%";
public function validatedBy()
{
return 'birthdate_not_before';
}
}

View File

@@ -17,49 +17,50 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Validator\Constraints;
namespace Chill\PersonBundle\Validator\Constraints\Person;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
*
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class BirthdateValidator extends ConstraintValidator
{
private $interval_spec = null;
public function __construct($interval_spec = null)
{
$this->interval_spec = $interval_spec;
}
public function validate($value, Constraint $constraint)
{
if ($value === NULL) {
return;
}
if (!$value instanceof \DateTime) {
throw new \LogicException('The input should a be a \DateTime interface,'
. (is_object($value) ? get_class($value) : gettype($value)));
}
$limitDate = $this->getLimitDate();
if ($limitDate < $value) {
$this->context->buildViolation($constraint->message)
->setParameter('%date%', $limitDate->format('d-m-Y'))
->setCode(Birthdate::BIRTHDATE_INVALID_CODE)
->addViolation();
}
}
/**
*
*
* @return \DateTime
*/
private function getLimitDate()

View File

@@ -0,0 +1,18 @@
<?php
namespace Chill\PersonBundle\Validator\Constraints\Person;
/**
* @Annotation
*/
class PersonHasCenter extends \Symfony\Component\Validator\Constraint
{
public string $message = "A center is required";
public function getTargets()
{
return [
self::CLASS_CONSTRAINT
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Chill\PersonBundle\Validator\Constraints\Person;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class PersonHasCenterValidator extends \Symfony\Component\Validator\ConstraintValidator
{
private bool $centerRequired;
public function __construct(ParameterBagInterface $parameterBag)
{
$this->centerRequired = $parameterBag->get('chill_person')['validation']['center_required'];
}
/**
* @inheritDoc
*/
public function validate($person, Constraint $constraint)
{
if (!$person instanceof Person) {
throw new UnexpectedTypeException($constraint, Person::class);
}
if (!$this->centerRequired) {
return;
}
if (NULL === $person->getCenter()) {
$this
->context
->buildViolation($constraint->message)
->atPath('center')
->addViolation()
;
}
}
}

View File

@@ -24,14 +24,6 @@ services:
tags:
- { name: console.command }
chill.person.form.type.select2maritalstatus:
class: Chill\PersonBundle\Form\Type\Select2MaritalStatusType
arguments:
- "@request_stack"
- "@doctrine.orm.entity_manager"
tags:
- { name: form.type, alias: select2_chill_marital_status }
chill.person.timeline.accompanying_period_opening:
class: Chill\PersonBundle\Timeline\TimelineAccompanyingPeriodOpening
arguments:
@@ -62,23 +54,22 @@ services:
- { name: security.voter }
- { name: chill.role }
chill.person.birthdate_validation:
class: Chill\PersonBundle\Validator\Constraints\BirthdateValidator
Chill\PersonBundle\Validator\Constraints\:
autowire: true
autoconfigure: true
resource: '../Validator/Constraints/'
# override default config, must be loaded after resource
Chill\PersonBundle\Validator\Constraints\BirthdateValidator:
arguments:
- "%chill_person.validation.birtdate_not_before%"
tags:
- { name: validator.constraint_validator, alias: birthdate_not_before }
Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequentialValidator:
autowire: true
tags:
- { name: validator.constraint_validator }
Chill\PersonBundle\Repository\:
autowire: true
autoconfigure: true
resource: '../Repository/'
tags: ['doctrine.repository_service']
Chill\PersonBundle\Controller\:
autowire: true

View File

@@ -12,9 +12,8 @@ services:
tags: ['controller.service_arguments']
Chill\PersonBundle\Controller\AccompanyingPeriodController:
arguments:
$eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface'
$validator: '@Symfony\Component\Validator\Validator\ValidatorInterface'
autowire: true
autoconfigure: true
tags: ['controller.service_arguments']
Chill\PersonBundle\Controller\PersonAddressController:

View File

@@ -1,5 +1,10 @@
services:
Chill\PersonBundle\Form\:
autowire: true
autoconfigure: true
resource: '../../Form/'
Chill\PersonBundle\Form\PersonType:
arguments:
- '%chill_person.person_fields%'
@@ -7,14 +12,7 @@ services:
tags:
- { name: form.type, alias: '@chill.person.form.person_creation' }
Chill\PersonBundle\Form\CreationPersonType:
arguments:
- '@chill.main.form.data_transformer.center_transformer'
- '@Chill\PersonBundle\Config\ConfigPersonAltNamesHelper'
tags:
- { name: form.type, alias: '@chill.main.form.person_creation' }
chill.person.accompanying_period_closing_motive:
Chill\PersonBundle\Form\Type\ClosingMotivePickerType:
class: Chill\PersonBundle\Form\Type\ClosingMotivePickerType
arguments:
$translatableStringHelper: '@Chill\MainBundle\Templating\TranslatableStringHelper'
@@ -28,60 +26,3 @@ services:
$config: "%chill_person.accompanying_period_fields%"
tags:
- { name: form.type }
chill.person.form.type.pick_person:
class: Chill\PersonBundle\Form\Type\PickPersonType
arguments:
- '@Chill\PersonBundle\Repository\PersonRepository'
- "@security.token_storage"
- "@chill.main.security.authorization.helper"
- '@Symfony\Component\Routing\Generator\UrlGeneratorInterface'
- '@Symfony\Component\Translation\TranslatorInterface'
tags:
- { name: form.type }
Chill\PersonBundle\Form\Type\PersonAltNameType:
arguments:
$configHelper: '@Chill\PersonBundle\Config\ConfigPersonAltNamesHelper'
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }
Chill\PersonBundle\Form\Type\PersonPhoneType:
arguments:
$phonenumberHelper: '@Chill\MainBundle\Phonenumber\PhonenumberHelper'
$em: '@Doctrine\ORM\EntityManagerInterface'
tags:
- { name: form.type }
Chill\PersonBundle\Form\SocialWork\SocialIssueType:
arguments:
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }
Chill\PersonBundle\Form\SocialWork\SocialActionType:
arguments:
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }
Chill\PersonBundle\Form\SocialWork\EvaluationType:
arguments:
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }
Chill\PersonBundle\Form\SocialWork\GoalType:
arguments:
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }
Chill\PersonBundle\Form\SocialWork\ResultType:
arguments:
$translatableStringHelper: '@chill.main.helper.translatable_string'
tags:
- { name: form.type }

View File

@@ -19,14 +19,7 @@ services:
# - { name: 'chill.menu_builder' }
#
Chill\PersonBundle\Menu\PersonMenuBuilder:
arguments:
$showAccompanyingPeriod: '%chill_person.accompanying_period%'
$translator: '@Symfony\Contracts\Translation\TranslatorInterface'
autowire: true
autoconfigure: true
tags:
- { name: 'chill.menu_builder' }
# Chill\PersonBundle\Menu\AccompanyingCourseMenuBuilder:
# arguments:
# $translator: '@Symfony\Contracts\Translation\TranslatorInterface'
# tags:
# - { name: 'chill.menu_builder' }

View File

@@ -1,6 +1,12 @@
services:
# note: the services.yaml file define some autoloading
chill.person.repository.person:
class: Chill\PersonBundle\Repository\PersonRepository
autowire: true
autoconfigure: true
Chill\PersonBundle\Repository\PersonRepository: '@chill.person.repository.person'
Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface: '@Chill\PersonBundle\Repository\PersonACLAwareRepository'
Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface: '@Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepository'

View File

@@ -1,25 +1,11 @@
services:
chill.person.search_person:
class: Chill\PersonBundle\Search\PersonSearch
arguments:
- "@doctrine.orm.entity_manager"
- "@security.token_storage"
- "@chill.main.security.authorization.helper"
- "@chill_main.paginator_factory"
calls:
- ['setContainer', ["@service_container"]]
Chill\PersonBundle\Search\PersonSearch:
autowire: true
tags:
- { name: chill.search, alias: 'person_regular' }
Chill\PersonBundle\Search\SimilarityPersonSearch:
arguments:
- "@doctrine.orm.entity_manager"
- "@security.token_storage"
- "@chill.main.security.authorization.helper"
- "@chill_main.paginator_factory"
- '@chill.person.search_person'
calls:
- ['setContainer', ["@service_container"]]
autowire: true
tags:
- { name: chill.search, alias: 'person_similarity' }

View File

@@ -1,16 +1,14 @@
services:
chill.person.security.authorization.person:
autowire: true
class: Chill\PersonBundle\Security\Authorization\PersonVoter
arguments:
- "@chill.main.security.authorization.helper"
tags:
- { name: security.voter }
- { name: chill.role }
Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter:
arguments:
- "@chill.main.security.authorization.helper"
autowire: true
tags:
- { name: security.voter }
- { name: chill.role }

View File

@@ -1,70 +1,3 @@
Chill\PersonBundle\Entity\Person:
properties:
firstName:
- NotBlank:
groups: [general, creation]
- Length:
min: 2
max: 255
minMessage: 'This name is too short. It must containt {{ limit }} chars'
maxMessage: 'This name is too long. It must containt {{ limit }} chars'
groups: [general, creation]
lastName:
- NotBlank:
groups: [general, creation]
- Length:
min: 2
max: 255
minMessage: 'This name is too short. It must containt {{ limit }} chars'
maxMessage: 'This name is too long. It must containt {{ limit }} chars'
groups: [general, creation]
birthdate:
- Date:
message: 'Birthdate not valid'
groups: [general, creation]
- Chill\PersonBundle\Validator\Constraints\Birthdate:
groups: [general, creation]
gender:
- NotNull:
groups: [general, creation]
#accompanyingPeriods:
# - Valid:
# traverse: true
email:
- Email:
groups: [general, creation]
message: 'The email is not valid'
checkMX: true
phonenumber:
- Regex:
pattern: '/^([\+{1}])([0-9\s*]{4,20})$/'
groups: [general, creation]
message: 'Invalid phone number: it should begin with the international prefix starting with "+", hold only digits and be smaller than 20 characters. Ex: +33123456789'
- Chill\MainBundle\Validation\Constraint\PhonenumberConstraint:
type: landline
groups: [ general, creation ]
mobilenumber:
- Regex:
pattern: '/^([\+{1}])([0-9\s*]{4,20})$/'
groups: [general, creation]
message: 'Invalid phone number: it should begin with the international prefix starting with "+", hold only digits and be smaller than 20 characters. Ex: +33623456789'
- Chill\MainBundle\Validation\Constraint\PhonenumberConstraint:
type: mobile
groups: [ general, creation ]
otherPhoneNumbers:
- Valid:
traverse: true
constraints:
- Callback:
callback: isAccompanyingPeriodValid
groups: [accompanying_period_consistent]
- Callback:
callback: isAddressesValid
groups: [addresses_consistent]
- Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential:
groups: [ 'household_memberships' ]
Chill\PersonBundle\Entity\AccompanyingPeriod:
properties:
openingDate:

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Allow to create persons without center
*/
final class Version20210831140339 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allow to create persons without center';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_person ALTER center_id DROP NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_person ALTER center_id SET NOT NULL');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Optimize trigram index on person fullname: create index for both center_id and fullname
*/
final class Version20210910161858 extends AbstractMigration
{
public function getDescription(): string
{
return 'Optimize trigram index on person fullname: create index for both center_id and fullname';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX fullnamecanonical_trgm_idx');
$this->addSql('CREATE INDEX fullnameCanonical_trgm_idx ON chill_person_person USING GIST (center_id, fullnameCanonical gist_trgm_ops)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX fullnamecanonical_trgm_idx');
$this->addSql('CREATE INDEX fullnameCanonical_trgm_idx ON chill_person_person USING GIST (fullnameCanonical gist_trgm_ops)');
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Create view for PersonCurrentAddress and related indexes
*/
final class Version20210915093624 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create view for PersonCurrentAddress and related indexes';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE VIEW view_chill_person_current_address AS
SELECT
cphm.person_id AS person_id,
cma.id AS address_id,
CASE WHEN cphm.startdate > COALESCE(cma.validfrom, '-infinity'::date) THEN cphm.startdate ELSE cma.validfrom END AS valid_from,
CASE WHEN COALESCE(cphm.enddate, 'infinity'::date) < COALESCE(cma.validto, 'infinity'::date) THEN cphm.enddate ELSE cma.validto END AS valid_to
FROM chill_person_household_members AS cphm
LEFT JOIN chill_person_household_to_addresses AS cphta ON cphta.household_id = cphm.household_id
LEFT JOIN chill_main_address AS cma ON cphta.address_id = cma.id
WHERE
cphm.sharedhousehold IS TRUE
AND
current_date between cphm.startdate AND coalesce(enddate, 'infinity'::date)
AND
current_date between cma.validfrom AND coalesce(validto, 'infinity'::date)
");
$this->addSql("CREATE INDEX chill_custom_main_address_filtering_idx ON chill_main_address USING btree (id, validfrom ASC, validto DESC)");
$this->addSql("CREATE INDEX chill_custom_person_household_members_sharing_idx ON chill_person_household_members USING btree (person_id, startdate ASC, enddate DESC, household_id) WHERE sharedhousehold IS TRUE");
}
public function down(Schema $schema): void
{
$this->addSql("DROP VIEW view_chill_person_current_address");
$this->addSql("DROP INDEX chill_custom_main_address_filtering_idx");
$this->addSql("DROP INDEX chill_custom_person_household_members_sharing_idx");
}
}