Merge remote-tracking branch 'origin/master' into doc/authorizaton-documentation-update

This commit is contained in:
2021-11-22 10:30:05 +01:00
300 changed files with 9998 additions and 5391 deletions

View File

@@ -1,20 +1,7 @@
<?php
/*
* Copyright (C) 2016-2019 Champs-Libres <info@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/>.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\CRUD\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
@@ -23,11 +10,8 @@ use Chill\PersonBundle\Entity\Person;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use BadMethodCallException;
/**
* Controller for entities attached as one-to-on to a person
*
*/
class OneToOneEntityPersonCRUDController extends CRUDController
{
protected function getTemplateFor($action, $entity, Request $request)
@@ -35,11 +19,11 @@ class OneToOneEntityPersonCRUDController extends CRUDController
if (!empty($this->crudConfig[$action]['template'])) {
return $this->crudConfig[$action]['template'];
}
switch ($action) {
case 'new':
return '@ChillPerson/CRUD/new.html.twig';
case 'edit':
case 'edit':
return '@ChillPerson/CRUD/edit.html.twig';
case 'index':
return '@ChillPerson/CRUD/index.html.twig';
@@ -49,41 +33,41 @@ class OneToOneEntityPersonCRUDController extends CRUDController
. "action");
}
}
protected function getEntity($action, $id, Request $request): ?object
{
$entity = parent::getEntity($action, $id, $request);
if (NULL === $entity) {
$entity = $this->createEntity($action, $request);
$person = $this->getDoctrine()
->getManager()
->getRepository(Person::class)
->find($id);
$entity->setPerson($person);
}
return $entity;
}
protected function onPreFlush(string $action, $entity, FormInterface $form, Request $request)
{
$this->getDoctrine()->getManager()->persist($entity);
}
protected function onPostFetchEntity($action, Request $request, $entity): ?Response
{
if (FALSE === $this->getDoctrine()->getManager()->contains($entity)) {
return new RedirectResponse($this->generateRedirectOnCreateRoute($action, $request, $entity));
}
return null;
}
protected function generateRedirectOnCreateRoute($action, Request $request, $entity)
{
throw new BadMethodCallException("not implemtented yet");
throw new BadMethodCallException('Not implemented yet.');
}
}

View File

@@ -4,6 +4,8 @@ namespace Chill\PersonBundle\Command;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
@@ -360,6 +362,7 @@ EOF
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$headers = $rawHeaders = [];
$this->input = $input;
$this->output = $output;
@@ -453,15 +456,15 @@ EOF
}
/**
*
* @param type $firstRow
* @return array where keys are column number, and value is information mapped
*/
protected function processingHeaders($firstRow)
protected function processingHeaders(array $firstRow): array
{
$availableOptions = array_map(function($m) { return $m[0]; }, self::$mapping);
$matchedColumnHeaders = array();
$headers = array();
$availableOptions = array_map(
static fn (array $m) => $m[0],
self::$mapping
);
$matchedColumnHeaders = $headers = [];
foreach($availableOptions as $option) {
$matchedColumnHeaders[$option] = $this->input->getOption($option);
@@ -482,18 +485,16 @@ EOF
}
/**
*
* @param array $row
* @param array $headers the processed header : an array as prepared by self::processingHeaders
* @return Person
* @throws \Exception
*/
protected function createPerson($row, $headers)
protected function createPerson(array $row, array $headers): Person
{
// trying to get the opening date
$openingDateString = trim($row[array_search('opening_date', $headers)]);
$openingDate = $this->processDate($openingDateString, $this->input->getOption('opening_date_format'));
// @TODO: Fix the constructor parameter, $openingDate does not exists.
$person = $openingDate instanceof \DateTime ? new Person($openingDate) : new Person();
// add the center
$center = $this->getCenter($row, $headers);
@@ -580,30 +581,27 @@ EOF
}
/**
* @param $row
* @param $headers
* @return Center|mixed|null|object
*/
protected function getCenter($row, $headers)
protected function getCenter(array $row, array $headers)
{
if ($this->input->hasOption('force-center') && !empty($this->input->getOption('force-center'))) {
return $this->em->getRepository('ChillMainBundle:Center')
->find($this->input->getOption('force-center'));
} else {
$columnCenter = \array_search('center', $headers);
$centerName = \trim($row[$columnCenter]);
return $this->em->getRepository(Center::class)->find($this->input->getOption('force-center'));
}
try {
return $this->em->createQuery('SELECT c FROM ChillMainBundle:Center c '
. 'WHERE c.name = :center_name')
->setParameter('center_name', $centerName)
->getSingleResult()
;
} catch (\Doctrine\ORM\NonUniqueResultException $e) {
return $this->guessCenter($centerName);
} catch (\Doctrine\ORM\NoResultException $e) {
return $this->guessCenter($centerName);
}
$columnCenter = array_search('center', $headers);
$centerName = trim($row[$columnCenter]);
try {
return $this
->em
->createQuery('SELECT c FROM ChillMainBundle:Center c WHERE c.name = :center_name')
->setParameter('center_name', $centerName)
->getSingleResult();
} catch (NonUniqueResultException $e) {
return $this->guessCenter($centerName);
} catch (NoResultException $e) {
return $this->guessCenter($centerName);
}
}
@@ -959,6 +957,8 @@ EOF
$table->setHeaders(array('#', 'label', 'value'));
$i = 0;
$matchingTableRowAnswer = [];
foreach($answers as $key => $answer) {
$table->addRow(array(
$i, $answer, $key

View File

@@ -24,27 +24,29 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\MainBundle\Entity\Scope;
use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepository;
use Symfony\Component\Workflow\Registry;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
class AccompanyingCourseApiController extends ApiController
final class AccompanyingCourseApiController extends ApiController
{
protected EventDispatcherInterface $eventDispatcher;
protected ValidatorInterface $validator;
private AccompanyingPeriodACLAwareRepository $accompanyingPeriodACLAwareRepository;
private EventDispatcherInterface $eventDispatcher;
private ValidatorInterface $validator;
private Registry $registry;
private ReferralsSuggestionInterface $referralAvailable;
public function __construct(
EventDispatcherInterface $eventDispatcher,
ValidatorInterface $validator,
Registry $registry,
AccompanyingPeriodACLAwareRepository $accompanyingPeriodACLAwareRepository,
ReferralsSuggestionInterface $referralAvailable
) {
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
$this->registry = $registry;
$this->accompanyingPeriodACLAwareRepository = $accompanyingPeriodACLAwareRepository;
$this->referralAvailable = $referralAvailable;
}
@@ -54,10 +56,14 @@ class AccompanyingCourseApiController extends ApiController
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$this->checkACL('confirm', $request, $_format, $accompanyingPeriod);
$workflow = $this->registry->get($accompanyingPeriod);
$workflow = $this->registry->get($accompanyingPeriod);
if (FALSE === $workflow->can($accompanyingPeriod, 'confirm')) {
throw new BadRequestException('It is not possible to confirm this period');
// throw new BadRequestException('It is not possible to confirm this period');
$errors = $this->validator->validate($accompanyingPeriod, null, [$accompanyingPeriod::STEP_CONFIRMED]);
if( count($errors) > 0 ){
return $this->json($errors, 422);
}
}
$workflow->apply($accompanyingPeriod, 'confirm');
@@ -109,6 +115,13 @@ $workflow = $this->registry->get($accompanyingPeriod);
public function resourceApi($id, Request $request, string $_format): Response
{
$accompanyingPeriod = $this->getEntity('resource', $id, $request);
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
return $this->json($errors, 422);
}
return $this->addRemoveSomething('resource', $id, $request, $_format, 'resource', Resource::class);
}
@@ -198,6 +211,18 @@ $workflow = $this->registry->get($accompanyingPeriod);
return null;
}
/**
* @ParamConverter("person", options={"id" = "person_id"})
*/
public function getAccompanyingPeriodsByPerson(Person $person){
$accompanyingPeriods = $person->getCurrentAccompanyingPeriods();
$accompanyingPeriodsChecked = array_filter($accompanyingPeriods,
function(AccompanyingPeriod $period){
return $this->isGranted(AccompanyingPeriodVoter::SEE, $period);
});
return $this->json(\array_values($accompanyingPeriodsChecked), Response::HTTP_OK, [], ['groups' => [ 'read']]);
}
/**
* @Route("/api/1.0/person/accompanying-course/{id}/referrers-suggested.{_format}",
* requirements={ "_format"="json"},

View File

@@ -11,7 +11,10 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Translation\TranslatorInterface;
use Symfony\Component\Form\Form;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
class AccompanyingCourseWorkController extends AbstractController
{
@@ -19,17 +22,20 @@ class AccompanyingCourseWorkController extends AbstractController
private SerializerInterface $serializer;
private AccompanyingPeriodWorkRepository $workRepository;
private PaginatorFactory $paginator;
private LoggerInterface $chillLogger;
public function __construct(
TranslatorInterface $trans,
SerializerInterface $serializer,
AccompanyingPeriodWorkRepository $workRepository,
PaginatorFactory $paginator
PaginatorFactory $paginator,
LoggerInterface $chillLogger
) {
$this->trans = $trans;
$this->serializer = $serializer;
$this->workRepository = $workRepository;
$this->paginator = $paginator;
$this->chillLogger = $chillLogger;
}
/**
@@ -106,4 +112,66 @@ class AccompanyingCourseWorkController extends AbstractController
'paginator' => $paginator
]);
}
/**
* @Route(
* "{_locale}/person/accompanying-period/work/{id}/delete",
* name="chill_person_accompanying_period_work_delete",
* methods={"GET", "POST", "DELETE"}
* )
*/
public function deleteWork(AccompanyingPeriodWork $work, Request $request): Response
{
// TODO ACL
$em = $this->getDoctrine()->getManager();
$form = $this->createDeleteForm($work->getId());
if ($request->getMethod() === Request::METHOD_DELETE) {
$form->handleRequest($request);
if ($form->isValid()) {
$this->chillLogger->notice("An accompanying period work has been removed", [
'by_user' => $this->getUser()->getUsername(),
'work_id' => $work->getId(),
'accompanying_period_id' => $work->getAccompanyingPeriod()->getId()
]);
$em->remove($work);
$em->flush();
$this->addFlash(
'success',
$this->trans->trans("The accompanying period work has been successfully removed.")
);
return $this->redirectToRoute('chill_person_accompanying_period_work_list', [
'id' => $work->getAccompanyingPeriod()->getId()
]);
}
}
return $this->render('@ChillPerson/AccompanyingCourseWork/delete.html.twig', [
'accompanyingCourse' => $work->getAccompanyingPeriod(),
'work' => $work,
'delete_form' => $form->createView()
]);
}
private function createDeleteForm(int $id): Form
{
$params = [];
$params['id'] = $id;
return $this->createFormBuilder()
->setAction($this->generateUrl('chill_person_accompanying_period_work_delete', $params))
->setMethod('DELETE')
->add('submit', SubmitType::class, ['label' => 'Delete'])
->getForm()
;
}
}

View File

@@ -47,13 +47,22 @@ class HouseholdApiController extends ApiController
$count = $this->householdRepository->countByAccompanyingPeriodParticipation($person);
$paginator = $this->getPaginatorFactory()->create($count);
if ($count === 0) {
$households = [];
} else {
$households = $this->householdRepository->findByAccompanyingPeriodParticipation($person,
$households = [];
if ($count !== 0) {
$allHouseholds = $this->householdRepository->findByAccompanyingPeriodParticipation($person,
$paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber());
}
$currentHouseholdPerson = $person->getCurrentHousehold();
foreach ($allHouseholds as $h) {
if ($h !== $currentHouseholdPerson) {
array_push($households, $h);
}
}
if (null !== $currentHouseholdPerson) {
$count = $count - 1;
$paginator = $this->getPaginatorFactory()->create($count);
}
}
$collection = new Collection($households, $paginator);
return $this->json($collection, Response::HTTP_OK, [],

View File

@@ -9,6 +9,8 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Translation\TranslatorInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Chill\PersonBundle\Entity\Household\Household;
@@ -26,13 +28,19 @@ class HouseholdController extends AbstractController
private PositionRepository $positionRepository;
private SerializerInterface $serializer;
private Security $security;
public function __construct(TranslatorInterface $translator, PositionRepository $positionRepository, Security $security)
{
public function __construct(
TranslatorInterface $translator,
PositionRepository $positionRepository,
SerializerInterface $serializer,
Security $security
) {
$this->translator = $translator;
$this->positionRepository = $positionRepository;
$this->serializer = $serializer;
$this->security = $security;
}
@@ -211,9 +219,13 @@ class HouseholdController extends AbstractController
*/
public function showRelationship(Request $request, Household $household)
{
$jsonString = $this->serializer->serialize($household->getCurrentPersons(),
'json', [ AbstractNormalizer::GROUPS => ['read']]);
return $this->render('@ChillPerson/Household/relationship.html.twig',
[
'household' => $household
'household' => $household,
'persons' => $jsonString
]
);
}

View File

@@ -38,7 +38,7 @@ class HouseholdMemberController extends ApiController
$this->translator = $translator;
$this->periodRepository = $periodRepository;
}
/**
* @Route(
* "/api/1.0/person/household/members/move.{_format}",
@@ -75,7 +75,7 @@ class HouseholdMemberController extends ApiController
}
foreach ($editor->getPersistable() as $el) {
$em->persist($el);
$em->persist($el);
}
$em->flush();
@@ -89,8 +89,8 @@ class HouseholdMemberController extends ApiController
*
* * persons[]: an id of the person to add to the form
* * household: the id of the destination household
* * allow_leave_without_household: if present, the editor will allow
* to leave household without joining another
* * allow_leave_without_household: if present, the editor will allow
* to leave household without joining another
*
* @Route(
* "/{_locale}/person/household/members/editor",
@@ -105,7 +105,7 @@ class HouseholdMemberController extends ApiController
$ids = $request->query->get('persons', []);
if (0 === count($ids)) {
throw new BadRequestExceptions("parameters persons in query ".
throw new BadRequestException("parameters persons in query ".
"is not an array or empty");
}
@@ -114,7 +114,7 @@ class HouseholdMemberController extends ApiController
;
foreach ($persons as $person) {
$this->denyAccessUnlessGranted(PersonVoter::SEE, $person,
$this->denyAccessUnlessGranted(PersonVoter::SEE, $person,
"You are not allowed to see person with id {$person->getId()}"
);
}
@@ -140,11 +140,11 @@ class HouseholdMemberController extends ApiController
;
$data = [
'persons' => $persons ?? false ?
'persons' => $persons ?? false ?
$this->getSerializer()->normalize($persons, 'json', [ 'groups' => [ 'read' ]]) : [],
'household' => $household ?? false ?
$this->getSerializer()->normalize($household, 'json', [ 'groups' => [ 'read' ]]) : null,
'positions' =>
'positions' =>
$this->getSerializer()->normalize($positions, 'json', [ 'groups' => [ 'read' ]]),
'allowHouseholdCreate' => $allowHouseholdCreate ?? true,
'allowHouseholdSearch' => $allowHouseholdSearch ?? true,
@@ -182,7 +182,7 @@ class HouseholdMemberController extends ApiController
// TODO ACL
$form = $this->createForm(HouseholdMemberType::class, $member, [
'validation_groups' => [ 'household_memberships' ]
'validation_groups' => [ 'household_memberships' ]
]);
$form->handleRequest($request);
@@ -190,12 +190,12 @@ class HouseholdMemberController extends ApiController
$this->getDoctrine()->getManager()->flush();
$this->addFlash('success', $this->translator
->trans('household.successfully saved member'))
->trans('household.successfully saved member'))
;
return $this->redirect(
$request->get('returnPath', null) ??
$this->generator->generate('chill_person_household_summary', [ 'household_id' =>
$this->generator->generate('chill_person_household_summary', [ 'household_id' =>
$member->getHousehold()->getId() ])
);
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\Relationships\RelationshipRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
class RelationshipApiController extends ApiController
{
private ValidatorInterface $validator;
private RelationshipRepository $repository;
public function __construct(ValidatorInterface $validator, RelationshipRepository $repository)
{
$this->validator = $validator;
$this->repository = $repository;
}
/**
* @ParamConverter("person", options={"id" = "person_id"})
*/
public function getRelationshipsByPerson(Person $person)
{
//TODO: add permissions? (voter?)
$relationships = $this->repository->findByPerson($person);
return $this->json(\array_values($relationships), Response::HTTP_OK, [], ['groups' => [ 'read']]);
}
}

View File

@@ -1,87 +1,62 @@
<?php
/*
* Copyright (C) 2015 Champs-Libres Coopérative <info@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/>.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Chill\MainBundle\Timeline\TimelineBuilder;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Symfony\Component\Security\Core\Role\Role;
class TimelinePersonController extends AbstractController
{
protected EventDispatcherInterface $eventDispatcher;
protected TimelineBuilder $timelineBuilder;
protected PaginatorFactory $paginatorFactory;
/**
* TimelinePersonController constructor.
*
* @param EventDispatcherInterface $eventDispatcher
*/
public function __construct(
EventDispatcherInterface $eventDispatcher,
TimelineBuilder $timelineBuilder,
PaginatorFactory $paginatorFactory,
AuthorizationHelper $authorizationHelper
PaginatorFactory $paginatorFactory
) {
$this->eventDispatcher = $eventDispatcher;
$this->timelineBuilder = $timelineBuilder;
$this->paginatorFactory = $paginatorFactory;
$this->authorizationHelper = $authorizationHelper;
}
public function personAction(Request $request, $person_id)
{
$person = $this->getDoctrine()
->getRepository('ChillPersonBundle:Person')
->getRepository(Person::class)
->find($person_id);
if ($person === NULL) {
throw $this->createNotFoundException();
}
$this->denyAccessUnlessGranted(PersonVoter::SEE, $person);
$nbItems = $this->timelineBuilder->countItems('person',
$nbItems = $this->timelineBuilder->countItems('person',
[ 'person' => $person ]
);
$paginator = $this->paginatorFactory->create($nbItems);
$event = new PrivacyEvent($person, array('action' => 'timeline'));
$this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event);
return $this->render('ChillPersonBundle:Timeline:index.html.twig', array
(
'timeline' => $this->timelineBuilder->getTimelineHTML(
'person',
'person',
array('person' => $person),
$paginator->getCurrentPage()->getFirstItemNumber(),
$paginator->getItemsPerPage()

View File

@@ -0,0 +1,40 @@
<?php
namespace Chill\PersonBundle\DataFixtures\Helper;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
trait PersonRandomHelper
{
private array $randPersons = [];
private ?int $countPersons = null;
protected function getRandomPerson(EntityManagerInterface $em): Person
{
$fetchBy = 5;
if (null === $this->countPersons) {
$qb = $em->createQueryBuilder();
$this->countPersons = $qb->select('count(p)')
->from(Person::class, 'p')
->getQuery()
->getSingleScalarResult()
;
}
if ([] === $this->randPersons) {
$qb = $em->createQueryBuilder();
$this->randPersons = $qb
->select('p')
->from(Person::class, 'p')
->getQuery()
->setFirstResult(\random_int(0, $this->countPersons - $fetchBy))
->setMaxResults($fetchBy)
->getResult()
;
}
return \array_pop($this->randPersons);
}
}

View File

@@ -40,7 +40,7 @@ class LoadAccompanyingPeriodOrigin extends AbstractFixture implements OrderedFix
public function getOrder()
{
return 10005;
return 9000;
}
private $phoneCall = ['en' => 'phone call', 'fr' => 'appel téléphonique'];

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\PersonBundle\Entity\Person;
@@ -24,6 +26,8 @@ class LoadHousehold extends Fixture implements DependentFixtureInterface
private CONST NUMBER_OF_HOUSEHOLD = 10;
private array $personIds;
public function __construct(MembersEditorFactory $editorFactory, EntityManagerInterface $em)
{
$this->editorFactory = $editorFactory;
@@ -149,30 +153,30 @@ class LoadHousehold extends Fixture implements DependentFixtureInterface
private function preparePersonIds()
{
// @TODO: Remove this and make this service stateless
$this->personIds = $this->em
->createQuery('SELECT p.id FROM '.Person::class.' p '.
'JOIN p.center c '.
'WHERE c.name = :center '
)
->setParameter('center', 'Center A')
->getScalarResult()
;
->getScalarResult();
\shuffle($this->personIds);
}
private function getRandomPersons(int $min, int $max)
private function getRandomPersons(int $min, int $max): array
{
$persons = [];
$nb = \random_int($min, $max);
for ($i=0; $i < $nb; $i++) {
$personId = \array_pop($this->personIds)['id'];
$persons[] = $this->em->getRepository(Person::class)
->find($personId)
;
$persons[] = $this->em->getRepository(Person::class)->find($personId);
}
return $persons ?? [];
return $persons;
}
public function getDependencies()

View File

@@ -10,8 +10,8 @@ class LoadHouseholdPosition extends Fixture
{
const POSITIONS_DATA = [
["Adulte", true, true, 1.0, self::ADULT ],
["Enfants", true, false, 2.0, self::CHILD ],
["Enfants hors ménage", false, false, 3.0, self::CHILD_OUT ]
["Enfant", true, false, 2.0, self::CHILD ],
["Enfant hors ménage", false, false, 3.0, self::CHILD_OUT ]
];
const ADULT = "position_adulte";

View File

@@ -106,6 +106,8 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
protected UserRepository $userRepository;
public const PERSON = 'person';
public function __construct(
Registry $workflowRegistry,
SocialIssueRepository $socialIssueRepository,
@@ -247,7 +249,9 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
if (\random_int(0, 10) > 3) {
// always add social scope:
$accompanyingPeriod->addScope($this->getReference('scope_social'));
$origin = $this->getReference(LoadAccompanyingPeriodOrigin::ACCOMPANYING_PERIOD_ORIGIN);
$accompanyingPeriod->setOrigin($origin);
$accompanyingPeriod->setIntensity('regular');
$accompanyingPeriod->setAddressLocation($this->createAddress());
$manager->persist($accompanyingPeriod->getAddressLocation());
$workflow = $this->workflowRegistry->get($accompanyingPeriod);
@@ -257,6 +261,8 @@ class LoadPeople extends AbstractFixture implements OrderedFixtureInterface, Con
$manager->persist($person);
$manager->persist($accompanyingPeriod);
echo "add person'".$person->__toString()."'\n";
$this->addReference(self::PERSON.$person->getId(), $person);
}
private function getRandomUser(): User

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\PersonBundle\Entity\Relationships\Relation;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
class LoadRelations extends Fixture implements FixtureGroupInterface
{
public const RELATION_KEY = 'relations';
public const RELATIONS = [
['title' => ['fr' => 'Mère'], 'reverseTitle' => ['fr' => 'Fille']],
['title' => ['fr' => 'Mère'], 'reverseTitle' => ['fr' => 'Fils']],
['title' => ['fr' => 'Père'], 'reverseTitle' => ['fr' => 'Fille']],
['title' => ['fr' => 'Père'], 'reverseTitle' => ['fr' => 'Fils']],
['title' => ['fr' => 'Frère'], 'reverseTitle' => ['fr' => 'Frère']],
['title' => ['fr' => 'Soeur'], 'reverseTitle' => ['fr' => 'Soeur']],
['title' => ['fr' => 'Frère'], 'reverseTitle' => ['fr' => 'Soeur']],
['title' => ['fr' => 'Demi-frère'], 'reverseTitle' => ['fr' => 'Demi-frère']],
['title' => ['fr' => 'Demi-soeur'], 'reverseTitle' => ['fr' => 'Demi-soeur']],
['title' => ['fr' => 'Demi-frère'], 'reverseTitle' => ['fr' => 'Demi-soeur']],
['title' => ['fr' => 'Oncle'], 'reverseTitle' => ['fr' => 'Neveu']],
['title' => ['fr' => 'Oncle'], 'reverseTitle' => ['fr' => 'Nièce']],
['title' => ['fr' => 'Tante'], 'reverseTitle' => ['fr' => 'Neveu']],
['title' => ['fr' => 'Tante'], 'reverseTitle' => ['fr' => 'Nièce']],
];
public static function getGroups(): array
{
return ['person_relations'];
}
public function load(ObjectManager $manager)
{
foreach (self::RELATIONS as $key => $value){
print "Creating a new relation type: relation" . $value['title']['fr'] . "reverse relation: " . $value['reverseTitle']['fr'] . "\n";
$relation = new Relation();
$relation->setTitle($value['title'])
->setReverseTitle($value['reverseTitle']);
$manager->persist($relation);
$this->addReference(self::RELATION_KEY.$key, $relation);
}
$manager->flush();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\MainBundle\DataFixtures\ORM\LoadUsers;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectManager;
use Chill\PersonBundle\Entity\Relationships\Relationship;
class LoadRelationships extends Fixture implements DependentFixtureInterface
{
use PersonRandomHelper;
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function getDependencies()
{
return [
LoadPeople::class,
LoadRelations::class
];
}
public function load(ObjectManager $manager)
{
for ($i = 0; $i < 15; $i++) {
$user = $this->getRandomUser();
$date = new \DateTimeImmutable();
$relationship = (new Relationship())
->setFromPerson($this->getRandomPerson($this->em))
->setToPerson($this->getRandomPerson($this->em))
->setRelation($this->getReference(LoadRelations::RELATION_KEY.
\random_int(0, count(LoadRelations::RELATIONS) - 1)))
->setReverse((bool) random_int(0, 1))
->setCreatedBy($user)
->setUpdatedBy($user)
->setCreatedAt($date)
->setUpdatedAt($date)
;
$manager->persist($relationship);
}
$manager->flush();
}
private function getRandomUser(): User
{
$userRef = array_rand(LoadUsers::$refs);
return $this->getReference($userRef);
}
}

View File

@@ -1,32 +1,30 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\PersonBundle\Service\Import\SocialWorkMetadata;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use League\Csv\Reader;
class LoadSocialWorkMetadata extends \Doctrine\Bundle\FixturesBundle\Fixture implements \Doctrine\Common\DataFixtures\OrderedFixtureInterface
class LoadSocialWorkMetadata extends Fixture implements OrderedFixtureInterface
{
private SocialWorkMetadata $importer;
/**
* @param SocialWorkMetadata $importer
*/
public function __construct(SocialWorkMetadata $importer)
{
$this->importer = $importer;
}
/**
* @inheritDoc
*/
public function load(ObjectManager $manager)
{
try {
$csv = Reader::createFromPath(__DIR__.'/data/social_work_metadata.csv');
} catch (Throwable $e) {
throw new Exception('Error while loading CSV.',0, $e);
} catch (\Throwable $e) {
throw new \Exception('Error while loading CSV.',0, $e);
}
$csv->setDelimiter(";");
@@ -34,9 +32,6 @@ class LoadSocialWorkMetadata extends \Doctrine\Bundle\FixturesBundle\Fixture imp
$this->importer->import($csv);
}
/**
* @inheritDoc
*/
public function getOrder()
{
return 9500;

View File

@@ -86,13 +86,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$loader->load('services/security.yaml');
$loader->load('services/doctrineEventListener.yaml');
// load service advanced search only if configure
if ($config['search']['search_by_phone'] != 'never') {
$loader->load('services/search_by_phone.yaml');
$container->setParameter('chill_person.search.search_by_phone',
$config['search']['search_by_phone']);
}
if ($container->getParameter('chill_person.accompanying_period') !== 'hidden') {
$loader->load('services/exports_accompanying_period.yaml');
}
@@ -585,6 +578,14 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
]
],
'findAccompanyingPeriodsByPerson' => [
'path' => '/by-person/{person_id}.{_format}',
'controller_action' => 'getAccompanyingPeriodsByPerson',
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
]
]
]
],
[
@@ -862,6 +863,59 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
],
]
],
[
'class' => \Chill\PersonBundle\Entity\Relationships\Relationship::class,
'controller' => \Chill\PersonBundle\Controller\RelationshipApiController::class,
'name' => 'relationship_by_person',
'base_path' => '/api/1.0/relations/relationship',
'base_role' => 'ROLE_USER',
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_PATCH => true,
Request::METHOD_DELETE => true,
],
'roles' => [
Request::METHOD_POST => 'ROLE_USER',
Request::METHOD_PATCH => 'ROLE_USER',
Request::METHOD_DELETE => 'ROLE_USER',
]
],
'relationship-by-person' => [
'path' => '/by-person/{person_id}.json',
'controller_action' => 'getRelationshipsByPerson',
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
'roles' => [
Request::METHOD_GET => 'ROLE_USER',
Request::METHOD_HEAD => 'ROLE_USER',
]
],
]
],
[
'class' => \Chill\PersonBundle\Entity\Relationships\Relation::class,
'name' => 'relations',
'base_path' => '/api/1.0/relations/relation',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
],
]
]);
}

View File

@@ -27,19 +27,6 @@ class Configuration implements ConfigurationInterface
$rootNode
->canBeDisabled()
->children()
->arrayNode('search')
->canBeDisabled()
->children()
->enumNode('search_by_phone')
->values(['always', 'on-domain', 'never'])
->defaultValue('on-domain')
->info('enable search by phone. \'always\' show the result '
. 'on every result. \'on-domain\' will show the result '
. 'only if the domain is given in the search box. '
. '\'never\' disable this feature')
->end()
->end() //children for 'search', parent = array node 'search'
->end() // array 'search', parent = children of root
->arrayNode('validation')
->canBeDisabled()
->children()

View File

@@ -45,6 +45,9 @@ use Chill\MainBundle\Entity\User;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\GroupSequenceProviderInterface;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ParticipationOverlap;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ResourceDuplicateCheck;
/**
* AccompanyingPeriod Class
@@ -54,9 +57,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period"=AccompanyingPeriod::class
* })
* @Assert\GroupSequenceProvider
*/
class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface,
HasScopesInterface, HasCentersInterface
HasScopesInterface, HasCentersInterface, GroupSequenceProviderInterface
{
/**
* Mark an accompanying period as "occasional"
@@ -132,6 +136,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* cascade={"persist", "remove"},
* orphanRemoval=true
* )
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_DRAFT})
*/
private $comments;
@@ -147,9 +152,10 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @var Collection
*
* @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class,
* mappedBy="accompanyingPeriod",
* mappedBy="accompanyingPeriod", orphanRemoval=true,
* cascade={"persist", "refresh", "remove", "merge", "detach"})
* @Groups({"read"})
* @ParticipationOverlap(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED})
*/
private $participations;
@@ -188,6 +194,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @ORM\ManyToOne(targetEntity=Origin::class)
* @ORM\JoinColumn(nullable=true)
* @Groups({"read", "write"})
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $origin;
@@ -195,8 +202,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* @var string
* @ORM\Column(type="string", nullable=true)
* @Groups({"read", "write"})
* @Assert\NotBlank(groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $intensity;
private $intensity = self::INTENSITY_OCCASIONAL;
/**
* @var Collection
@@ -210,6 +218,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* inverseJoinColumns={@ORM\JoinColumn(name="scope_id", referencedColumnName="id")}
* )
* @Groups({"read"})
* @Assert\Count(min=1, groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private $scopes;
@@ -256,6 +265,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* orphanRemoval=true
* )
* @Groups({"read"})
* @ResourceDuplicateCheck(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED, "Default", "default"})
*/
private $resources;
@@ -267,6 +277,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
* name="chill_person_accompanying_period_social_issues"
* )
* @Groups({"read"})
* @Assert\Count(min=1, groups={AccompanyingPeriod::STEP_CONFIRMED})
*/
private Collection $socialIssues;
@@ -533,7 +544,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
*/
public function actualParticipationsByHousehold(): array
{
$participations = $this->getOPenParticipations()->toArray();
$participations = $this->getOpenParticipations()->toArray();
$households = [];
foreach ($participations as $p) {
@@ -606,6 +617,14 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
return $participation;
}
/**
* Remove Participation
*/
public function removeParticipation(AccompanyingPeriodParticipation $participation)
{
$participation->setAccompanyingPeriod(null);
}
/**
* Remove Person
@@ -1025,9 +1044,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
{
if (count($this->getPersons()) === 0){
return null;
} else {
return $this->getPersons()->first()->getCenter();
}
return $this->getPersons()->first()->getCenter();
}
/**
@@ -1055,7 +1074,7 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
*/
public function getAvailablePersonLocation(): Collection
{
return $this->getOPenParticipations()
return $this->getOpenParticipations()
->filter(function(AccompanyingPeriodParticipation $p) {
return $p->getPerson()->hasCurrentHouseholdAddress();
})
@@ -1097,7 +1116,9 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
{
if ($this->getPersonLocation() instanceof Person) {
return 'person';
} elseif ($this->getAddressLocation() instanceof Address) {
}
if ($this->getAddressLocation() instanceof Address) {
return 'address';
} else {
return 'none';
@@ -1115,4 +1136,18 @@ class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
return $centers ?? null;
}
public function getGroupSequence()
{
if ($this->getStep() == self::STEP_DRAFT)
{
return [[self::STEP_DRAFT]];
} elseif ($this->getStep() == self::STEP_CONFIRMED)
{
return [[self::STEP_DRAFT, self::STEP_CONFIRMED]];
}
throw new \LogicException("no validation group permitted with this step");
}
}

View File

@@ -167,7 +167,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\OneToMany(
* targetEntity=AccompanyingPeriodWorkEvaluation::class,
* mappedBy="accompanyingPeriodWork",
* cascade={"persist"},
* cascade={"remove", "persist"},
* orphanRemoval=true
* )
* @Serializer\Groups({"read"})

View File

@@ -70,7 +70,8 @@ class AccompanyingPeriodWorkEvaluationDocument implements \Chill\MainBundle\Doct
/**
* @ORM\ManyToOne(
* targetEntity=StoredObject::class
* targetEntity=StoredObject::class,
* cascade={"remove"},
* )
* @Serializer\Groups({"read"})
*/

View File

@@ -33,7 +33,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period_resource")
* @ORM\Table(
* name="chill_person_accompanying_period_resource",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="person_unique", columns={"person_id", "accompanyingperiod_id"}),
* @ORM\UniqueConstraint(name="thirdparty_unique", columns={"thirdparty_id", "accompanyingperiod_id"})
* }
* )
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period_resource"=Resource::class
* })

View File

@@ -134,4 +134,11 @@ class AccompanyingPeriodParticipation
{
return $this->endDate === null;
}
private function checkSameStartEnd()
{
if($this->endDate == $this->startDate) {
$this->accompanyingPeriod->removeParticipation($this);
}
}
}

View File

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

View File

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

View File

@@ -1,26 +1,8 @@
<?php
namespace Chill\PersonBundle\Entity;
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
* <http://www.champs-libres.coop>, <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Entity;
use ArrayIterator;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
@@ -50,16 +32,23 @@ 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;
use DateTimeInterface;
/**
* Person Class
*
* @ORM\Entity
* @ORM\Table(name="chill_person_person",
* indexes={@ORM\Index(
* indexes={
* @ORM\Index(
* name="person_names",
* columns={"firstName", "lastName"}
* )})
* ),
* @ORM\Index(
* name="person_birthdate",
* columns={"birthdate"}
* )
* })
* @ORM\HasLifecycleCallbacks()
* @DiscriminatorMap(typeProperty="type", mapping={
* "person"=Person::class
@@ -75,13 +64,12 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
{
/**
* The person's id
* @var integer
*
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
private ?int $id = null;
/**
* The person's first name
@@ -231,7 +219,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"}
* )
*/
private ?\DateTime $maritalStatusDate;
private ?\DateTime $maritalStatusDate = null;
/**
* Comment on marital status
@@ -264,7 +252,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's phonenumber
* @var string
*
* @ORM\Column(type="text", length=40, nullable=true)
* @ORM\Column(type="text")
* @Assert\Regex(
* pattern="/^([\+{1}])([0-9\s*]{4,20})$/",
* groups={"general", "creation"}
@@ -274,13 +262,13 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"}
* )
*/
private $phonenumber = '';
private string $phonenumber = '';
/**
* The person's mobile phone number
* @var string
*
* @ORM\Column(type="text", length=40, nullable=true)
* @ORM\Column(type="text")
* @Assert\Regex(
* pattern="/^([\+{1}])([0-9\s*]{4,20})$/",
* groups={"general", "creation"}
@@ -290,7 +278,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* groups={"general", "creation"}
* )
*/
private $mobilenumber = '';
private string $mobilenumber = '';
/**
* @var Collection
@@ -323,11 +311,10 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
/**
* The person's center
* @var Center
*
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")
*/
private $center;
private ?Center $center = null;
/**
* The person's accompanying periods (when the person was accompanied by the center)
@@ -390,19 +377,19 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
/**
* @ORM\Column(type="datetime", nullable=true, options={"default": NULL})
*/
private \DateTimeInterface $createdAt;
private $createdAt;
/**
* @ORM\ManyToOne(
* targetEntity=User::class
* )
*/
private User $updatedBy;
private $updatedBy;
/**
* @ORM\Column(type="datetime", nullable=true, options={"default": NULL})
*/
private \DateTimeInterface $updatedAt;
private $updatedAt;
/**
* @var boolean
@@ -731,12 +718,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return false;
}
/**
* Get id
*
* @return integer
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
@@ -862,16 +844,16 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
*
* If the person has a deathdate, calculate the age at the deathdate.
*
* @param string $at a valid string to create a DateTime
* @return int|null
* @param string $at A valid string to create a DateTime.
*/
public function getAge($at = 'now'): ?int
public function getAge(string $at = 'now'): ?int
{
if ($this->birthdate instanceof \DateTimeInterface) {
if ($this->deathdate instanceof \DateTimeInterface) {
return date_diff($this->birthdate, $this->deathdate)->format("%y");
return (int) date_diff($this->birthdate, $this->deathdate)->format('%y');
}
return date_diff($this->birthdate, date_create($at))->format("%y");
return (int) date_diff($this->birthdate, date_create($at))->format('%y');
}
return null;
@@ -931,13 +913,19 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* return gender as a Numeric form.
* This is used for translations
* @return int
* @deprecated Keep for legacy. Used in Chill 1.5 for feminize before icu translations
*/
public function getGenderNumeric()
{
if ($this->getGender() == self::FEMALE_GENDER) {
return 1;
} else {
return 0;
switch ($this->getGender()) {
case self::FEMALE_GENDER:
return 1;
case self::MALE_GENDER:
return 0;
case self::BOTH_GENDER:
return 2;
default:
return -1;
}
}
@@ -1106,9 +1094,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
/**
* Get nationality
*
* @return Chill\MainBundle\Entity\Country
* @return Country
*/
public function getNationality()
public function getNationality(): ?Country
{
return $this->nationality;
}
@@ -1176,9 +1164,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* @param string $phonenumber
* @return Person
*/
public function setPhonenumber($phonenumber = '')
public function setPhonenumber(?string $phonenumber = '')
{
$this->phonenumber = $phonenumber;
$this->phonenumber = (string) $phonenumber;
return $this;
}
@@ -1188,7 +1176,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
*
* @return string
*/
public function getPhonenumber()
public function getPhonenumber(): string
{
return $this->phonenumber;
}
@@ -1199,9 +1187,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* @param string $mobilenumber
* @return Person
*/
public function setMobilenumber($mobilenumber = '')
public function setMobilenumber(?string $mobilenumber = '')
{
$this->mobilenumber = $mobilenumber;
$this->mobilenumber = (string) $mobilenumber;
return $this;
}
@@ -1211,7 +1199,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
*
* @return string
*/
public function getMobilenumber()
public function getMobilenumber(): string
{
return $this->mobilenumber;
}
@@ -1329,7 +1317,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
*/
public function getLastAddress(DateTime $from = null)
{
return $this->getCurrentPersonAddress($from);
return $this->getCurrentPersonAddress();
}
/**
@@ -1823,6 +1811,11 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this;
}
public function getCreatedAt(): ?DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $datetime): self
{
$this->createdAt = $datetime;
@@ -1830,6 +1823,16 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
public function getUpdatedAt(): ?DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;

View File

@@ -9,7 +9,10 @@ use Doctrine\ORM\Mapping as ORM;
* Person Phones
*
* @ORM\Entity
* @ORM\Table(name="chill_person_phone")
* @ORM\Table(name="chill_person_phone",
* indexes={
* @ORM\Index(name="phonenumber", columns={"phonenumber"})
* })
*/
class PersonPhone
{
@@ -107,7 +110,7 @@ class PersonPhone
{
$this->date = $date;
}
public function isEmpty(): bool
{
return empty($this->getDescription()) && empty($this->getPhonenumber());

View File

@@ -0,0 +1,85 @@
<?php
namespace Chill\PersonBundle\Entity\Relationships;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\DiscriminatorColumn;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* @ORM\Entity()
* @ORM\Table(name="chill_person_relations")
* @DiscriminatorMap(typeProperty="type", mapping={
* "relation"=Relation::class
* })
*/
class Relation
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="json", nullable=true)
* @Serializer\Groups({"read"})
*/
private array $title = [];
/**
* @ORM\Column(type="json", nullable=true)
* @Serializer\Groups({"read"})
*/
private array $reverseTitle = [];
/**
* @ORM\Column(type="boolean", nullable=true)
* @Serializer\Groups({"read"})
*/
private bool $isActive = true;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?array
{
return $this->title;
}
public function setTitle(?array $title): self
{
$this->title = $title;
return $this;
}
public function getReverseTitle(): ?array
{
return $this->reverseTitle;
}
public function setReverseTitle(?array $reverseTitle): self
{
$this->reverseTitle = $reverseTitle;
return $this;
}
public function getIsActive(): bool
{
return $this->isActive;
}
public function setIsActive(?bool $isActive): self
{
$this->isActive = $isActive;
return $this;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Chill\PersonBundle\Entity\Relationships;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Relationships\Relation;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Doctrine\ORM\Mapping\DiscriminatorColumn;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* @ORM\Entity()
* @ORM\Table(name="chill_person_relationships")
* @DiscriminatorColumn(name="relation_id", type="integer")
* @DiscriminatorMap(typeProperty="type", mapping={
* "relationship"=Relationship::class
* })
*
*/
class Relationship implements TrackCreationInterface, TrackUpdateInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=false)
* @Assert\NotNull()
* @Serializer\Groups({"read", "write"})
*/
private ?Person $fromPerson = null;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=false)
* @Assert\NotNull()
* @Serializer\Groups({"read", "write"})
*/
private ?Person $toPerson = null;
/**
* @ORM\ManyToOne(targetEntity=Relation::class)
* @ORM\JoinColumn(nullable=false, name="relation_id", referencedColumnName="id")
* @Assert\NotNull()
* @Serializer\Groups({"read", "write"})
*/
private ?Relation $relation = null;
/**
* @ORM\Column(type="boolean")
* @Assert\Type(
* type="bool",
* message="This must be of type boolean"
* )
* @Serializer\Groups({"read", "write"})
*/
private bool $reverse;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
*/
private ?User $createdBy = null;
/**
* @ORM\Column(type="datetime_immutable")
*/
private ?DateTimeImmutable $createdAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private ?User $updatedBy = null;
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getFromPerson(): ?Person
{
return $this->fromPerson;
}
public function setFromPerson(?Person $fromPerson): self
{
$this->fromPerson = $fromPerson;
return $this;
}
public function getToPerson(): ?Person
{
return $this->toPerson;
}
public function setToPerson(?Person $toPerson): self
{
$this->toPerson = $toPerson;
return $this;
}
public function getReverse(): ?bool
{
return $this->reverse;
}
public function setReverse(bool $reverse): self
{
$this->reverse = $reverse;
return $this;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $user): self
{
$this->createdBy = $user;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
public function setUpdatedBy(?User $updatedBy): self
{
$this->updatedBy = $updatedBy;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeInterface $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getRelation(): ?Relation
{
return $this->relation;
}
public function setRelation(?Relation $relation): self
{
$this->relation = $relation;
return $this;
}
}

View File

@@ -143,6 +143,8 @@ final class CountryOfBirthAggregator implements AggregatorInterface, ExportEleme
public function getLabels($key, array $values, $data)
{
$labels = [];
if ($data['group_by_level'] === 'country') {
$qb = $this->countriesRepository->createQueryBuilder('c');
@@ -153,15 +155,17 @@ final class CountryOfBirthAggregator implements AggregatorInterface, ExportEleme
->getResult(\Doctrine\ORM\Query::HYDRATE_SCALAR);
// initialize array and add blank key for null values
$labels[''] = $this->translator->trans('without data');
$labels['_header'] = $this->translator->trans('Country of birth');
$labels = [
'' => $this->translator->trans('without data'),
'_header' => $this->translator->trans('Country of birth'),
];
foreach($countries as $row) {
$labels[$row['c_countryCode']] = $this->translatableStringHelper->localize($row['c_name']);
}
}
} elseif ($data['group_by_level'] === 'continent') {
if ($data['group_by_level'] === 'continent') {
$labels = array(
'EU' => $this->translator->trans('Europe'),
'AS' => $this->translator->trans('Asia'),
@@ -170,13 +174,12 @@ final class CountryOfBirthAggregator implements AggregatorInterface, ExportEleme
'SA' => $this->translator->trans('South America'),
'NA' => $this->translator->trans('North America'),
'OC' => $this->translator->trans('Oceania'),
'' => $this->translator->trans('without data'),
'' => $this->translator->trans('without data'),
'_header' => $this->translator->trans('Continent of birth')
);
}
return function($value) use ($labels) {
return function(string $value) use ($labels): string {
return $labels[$value];
};

View File

@@ -144,6 +144,8 @@ final class NationalityAggregator implements AggregatorInterface, ExportElementV
public function getLabels($key, array $values, $data)
{
$labels = [];
if ($data['group_by_level'] === 'country') {
$qb = $this->countriesRepository->createQueryBuilder('c');
@@ -154,15 +156,17 @@ final class NationalityAggregator implements AggregatorInterface, ExportElementV
->getResult(\Doctrine\ORM\Query::HYDRATE_SCALAR);
// initialize array and add blank key for null values
$labels[''] = $this->translator->trans('without data');
$labels['_header'] = $this->translator->trans('Nationality');
$labels = [
'' => $this->translator->trans('without data'),
'_header' => $this->translator->trans('Nationality'),
];
foreach($countries as $row) {
$labels[$row['c_countryCode']] = $this->translatableStringHelper->localize($row['c_name']);
}
}
} elseif ($data['group_by_level'] === 'continent') {
if ($data['group_by_level'] === 'continent') {
$labels = array(
'EU' => $this->translator->trans('Europe'),
'AS' => $this->translator->trans('Asia'),
@@ -176,8 +180,7 @@ final class NationalityAggregator implements AggregatorInterface, ExportElementV
);
}
return function($value) use ($labels) {
return function(string $value) use ($labels): string {
return $labels[$value];
};

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Export\Export;
use Chill\MainBundle\Export\ListInterface;
@@ -24,47 +26,29 @@ use Chill\MainBundle\Export\ExportElementValidatedInterface;
use Chill\CustomFieldsBundle\CustomFields\CustomFieldChoice;
/**
* Render a list of peoples
*
* @author julien
* Render a list of people.
*/
class ListPerson implements ListInterface, ExportElementValidatedInterface
{
/**
*
* @var EntityManagerInterface
*/
protected $entityManager;
protected EntityManagerInterface $entityManager;
/**
*
* @var TranslatorInterface
*/
protected $translator;
protected TranslatorInterface $translator;
/**
*
* @var TranslatableStringHelper
*/
protected $translatableStringHelper;
protected TranslatableStringHelper $translatableStringHelper;
/**
*
* @var CustomFieldProvider
*/
protected $customFieldProvider;
protected CustomFieldProvider $customFieldProvider;
protected $fields = array(
protected array $fields = [
'id', 'firstName', 'lastName', 'birthdate',
'placeOfBirth', 'gender', 'memo', 'email', 'phonenumber',
'mobilenumber', 'contactInfo', 'countryOfBirth', 'nationality',
'address_street_address_1', 'address_street_address_2',
'address_valid_from', 'address_postcode_label', 'address_postcode_code',
'address_country_name', 'address_country_code', 'address_isnoaddress'
);
];
private $slugs = [];
public function __construct(
EntityManagerInterface $em,
TranslatorInterface $translator,
@@ -77,11 +61,6 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
$this->customFieldProvider = $customFieldProvider;
}
/**
* {@inheritDoc}
*
* @param FormBuilderInterface $builder
*/
public function buildForm(FormBuilderInterface $builder)
{
$choices = array_combine($this->fields, $this->fields);
@@ -99,13 +78,13 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
'expanded' => true,
'choices' => $choices,
'label' => 'Fields to include in export',
'choice_attr' => function($val, $key, $index) {
'choice_attr' => static function(string $val): array {
// add a 'data-display-target' for address fields
if (substr($val, 0, 8) === 'address_') {
return ['data-display-target' => 'address_date'];
} else {
return [];
}
return [];
},
'constraints' => [new Callback(array(
'callback' => function($selected, ExecutionContextInterface $context) {
@@ -133,9 +112,10 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
public function validateForm($data, ExecutionContextInterface $context)
{
// get the field starting with address_
$addressFields = array_filter(function($el) {
return substr($el, 0, 8) === 'address_';
}, $this->fields);
$addressFields = array_filter(
$this->fields,
static fn(string $el): bool => substr($el, 0, 8) === 'address_'
);
// check if there is one field starting with address in data
if (count(array_intersect($data['fields'], $addressFields)) > 0) {
@@ -168,41 +148,23 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
->getResult();
}
/**
* {@inheritDoc}
*
* @return type
*/
public function getAllowedFormattersTypes()
{
return array(FormatterInterface::TYPE_LIST);
}
/**
* {@inheritDoc}
*
* @return string
*/
public function getDescription()
{
return "Create a list of people according to various filters.";
}
/**
* {@inheritDoc}
*
* @param type $key
* @param array $values
* @param type $data
* @return type
*/
public function getLabels($key, array $values, $data)
{
switch ($key) {
case 'birthdate':
// for birthdate, we have to transform the string into a date
// to format the date correctly.
return function($value) {
return static function($value) {
if ($value === '_header') { return 'birthdate'; }
if (empty($value))
@@ -257,31 +219,33 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
return $this->translatableStringHelper->localize(json_decode($value, true));
};
case 'address_isnoaddress':
return function($value) use ($key) {
if ($value === '_header') { return 'address.address_homeless'; }
if ($value) {
return 'X';
} else {
return '';
return static function(?string $value): string {
if ($value === '_header') {
return 'address.address_homeless';
}
if (null !== $value) {
return 'X';
}
return '';
};
default:
// for fields which are associated with person
if (in_array($key, $this->fields)) {
return function($value) use ($key) {
return static function($value) use ($key) {
if ($value === '_header') { return \strtolower($key); }
return $value;
};
} else {
return $this->getLabelForCustomField($key, $values, $data);
}
return $this->getLabelForCustomField($key, $values, $data);
}
}
private function getLabelForCustomField($key, array $values, $data)
{
// for fields which are custom fields
@@ -292,45 +256,39 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
$cfType = $this->customFieldProvider->getCustomFieldByType($cf->getType());
$defaultFunction = function($value) use ($cf) {
if ($value === '_header') {
return $this->translatableStringHelper->localize($cf->getName());
return $this->translatableStringHelper->localize($cf->getName());
}
return $this->customFieldProvider
->getCustomFieldByType($cf->getType())
->render(json_decode($value, true), $cf, 'csv');
};
if ($cfType instanceof CustomFieldChoice and $cfType->isMultiple($cf)) {
return function($value) use ($cf, $cfType, $key) {
$slugChoice = $this->extractInfosFromSlug($key)['additionnalInfos']['choiceSlug'];
$decoded = \json_decode($value, true);
if ($value === '_header') {
$label = $cfType->getChoices($cf)[$slugChoice];
return $this->translatableStringHelper->localize($cf->getName())
.' | '.$label;
}
if ($slugChoice === '_other' and $cfType->isChecked($cf, $choiceSlug, $decoded)) {
return $cfType->extractOtherValue($cf, $decoded);
} else {
return $cfType->isChecked($cf, $slugChoice, $decoded);
}
return $cfType->isChecked($cf, $slugChoice, $decoded);
};
} else {
return $defaultFunction;
}
return $defaultFunction;
}
/**
* {@inheritDoc}
*
* @param type $data
* @return type
*/
public function getQueryKeys($data)
{
$fields = array();
@@ -340,78 +298,55 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
$fields[] = $key;
}
}
// add the key from slugs and return
return \array_merge($fields, \array_keys($this->slugs));
}
/**
* clean a slug to be usable by DQL
*
* @param string $slugsanitize
* @param string $type the type of the customfield, if required (currently only for choices)
* @return string
* Clean a slug to be usable by DQL.
*/
private function slugToDQL($slug, $type = "default", array $additionalInfos = [])
private function slugToDQL(string $slug, string $type = "default", array $additionalInfos = []): string
{
$uid = 'slug_'.\uniqid();
$uid = 'slug_' . \uniqid('', true);
$this->slugs[$uid] = [
'slug' => $slug,
'type' => $type,
'additionnalInfos' => $additionalInfos
];
return $uid;
}
private function DQLToSlug($cleanedSlug)
{
{
return $this->slugs[$cleanedSlug]['slug'];
}
/**
*
* @param type $cleanedSlug
* @return an array with keys = 'slug', 'type', 'additionnalInfo'
* @return array An array with keys = 'slug', 'type', 'additionnalInfo'
*/
private function extractInfosFromSlug($slug)
private function extractInfosFromSlug($slug): array
{
return $this->slugs[$slug];
}
/**
* {@inheritDoc}
*
*/
public function getResult($query, $data)
{
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
}
/**
* {@inheritDoc}
*
* @return string
*/
public function getTitle()
{
return "List peoples";
}
/**
* {@inheritDoc}
*
*/
public function getType()
{
return Declarations::PERSON_TYPE;
}
/**
* {@inheritDoc}
*
*/
public function initiateQuery(array $requiredModifiers, array $acl, array $data = array())
{
$centers = array_map(function($el) { return $el['center']; }, $acl);
@@ -459,7 +394,7 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
foreach($cfType->getChoices($cf) as $choiceSlug => $label) {
$slug = $this->slugToDQL($cf->getSlug(), 'choice', [ 'choiceSlug' => $choiceSlug ]);
$qb->addSelect(
sprintf('GET_JSON_FIELD_BY_KEY(person.cFData, :slug%s) AS %s',
sprintf('GET_JSON_FIELD_BY_KEY(person.cFData, :slug%s) AS %s',
$slug, $slug));
$qb->setParameter(sprintf('slug%s', $slug), $cf->getSlug());
}
@@ -483,19 +418,11 @@ class ListPerson implements ListInterface, ExportElementValidatedInterface
return $qb;
}
/**
*
* {@inheritDoc}
*/
public function requiredRole()
{
return new Role(PersonVoter::LISTS);
}
/**
*
* {@inheritDoc}
*/
public function supportsModifiers()
{
return array(Declarations::PERSON_TYPE, Declarations::PERSON_IMPLIED_IN);

View File

@@ -1,40 +1,20 @@
<?php
/*
* Chill is a software for social workers
*
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Form\Event\CustomizeFormEvent;
use Chill\MainBundle\Repository\CenterRepository;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickCenterType;
use Chill\PersonBundle\Form\Type\GenderType;
use Chill\MainBundle\Form\Type\DataTransformer\CenterTransformer;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
@@ -42,26 +22,22 @@ final class CreationPersonType extends AbstractType
{
// TODO: This is only used in test.
// TODO: See if this is still valid and update accordingly.
const NAME = 'chill_personbundle_person_creation';
public const NAME = 'chill_personbundle_person_creation';
private CenterRepository $centerRepository;
/**
*
* @var ConfigPersonAltNamesHelper
*/
protected $configPersonAltNamesHelper;
private ConfigPersonAltNamesHelper $configPersonAltNamesHelper;
private EventDispatcherInterface $dispatcher;
private bool $askCenters;
public function __construct(
CenterRepository $centerRepository,
ConfigPersonAltNamesHelper $configPersonAltNamesHelper,
EventDispatcherInterface $dispatcher
EventDispatcherInterface $dispatcher,
ParameterBagInterface $parameterBag
) {
$this->centerTransformer = $centerRepository;
$this->configPersonAltNamesHelper = $configPersonAltNamesHelper;
$this->dispatcher = $dispatcher;
$this->askCenters = $parameterBag->get('chill_main')['acl']['form_show_centers'];
}
/**
@@ -78,12 +54,15 @@ final class CreationPersonType extends AbstractType
])
->add('gender', GenderType::class, array(
'required' => true, 'placeholder' => null
))
->add('center', PickCenterType::class, [
'required' => false,
'role' => PersonVoter::CREATE,
])
;
));
if ($this->askCenters) {
$builder
->add('center', PickCenterType::class, [
'required' => false,
'role' => PersonVoter::CREATE,
]);
}
if ($this->configPersonAltNamesHelper->hasAltNames()) {
$builder->add('altNames', PersonAltNameType::class, [

View File

@@ -144,7 +144,10 @@ class PersonType extends AbstractType
}
if ($this->config['phonenumber'] === 'visible') {
$builder->add('phonenumber', TelType::class, array('required' => false));
$builder->add('phonenumber', TelType::class, array(
'required' => false,
// 'placeholder' => '+33623124554' //TODO placeholder for phone numbers
));
}
if ($this->config['mobilenumber'] === 'visible') {
@@ -167,7 +170,8 @@ class PersonType extends AbstractType
'delete_empty' => function(PersonPhone $pp = null) {
return NULL === $pp || $pp->isEmpty();
},
'error_bubbling' => false
'error_bubbling' => false,
'empty_collection_explain' => 'No additional phone numbers'
]);
if ($this->config['email'] === 'visible') {

View File

@@ -1,83 +1,49 @@
<?php
/*
*
* Copyright (C) 2014-2021, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* 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/>.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\Form\SocialWork;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
/**
* Class SocialIssueType
*
* @package Chill\PersonBundle\Form
*/
class SocialIssueType extends AbstractType
{
/**
*
* @var TranslatableStringHelper
*/
protected $translatableStringHelper;
protected TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(TranslatableStringHelper $translatableStringHelper) {
public function __construct(
TranslatableStringHelperInterface $translatableStringHelper
) {
$this->translatableStringHelper = $translatableStringHelper;
}
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TranslatableStringFormType::class, [
'label' => 'Nom'
])
->add('parent', EntityType::class, [
'class' => SocialIssue::class,
'required' => false,
'choice_label' => function (SocialIssue $issue) {
return $this->translatableStringHelper->localize($issue->getTitle());
}
'choice_label' => fn (SocialIssue $issue): ?string => $this->translatableStringHelper->localize($issue->getTitle())
])
->add('desactivationDate', DateType::class, [
'attr' => ['class' => 'datepicker'],
'widget'=> 'single_text',
'format' => 'dd-MM-yyyy',
'required' => false,
]);
}
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('class', SocialIssue::class)
;
$resolver->setDefault('class', SocialIssue::class);
}
}

View File

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

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
@@ -34,7 +36,7 @@ final class AccompanyingPeriodWorkRepository implements ObjectRepository
public function findOneBy(array $criteria): ?AccompanyingPeriodWork
{
return $this->findOneBy($criteria);
return $this->repository->findOneBy($criteria);
}
public function getClassName()

View File

@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Repository\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMembers;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@@ -12,6 +14,6 @@ final class HouseholdMembersRepository
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(HouseholdMembers::class);
$this->repository = $entityManager->getRepository(HouseholdMember::class);
}
}

View File

@@ -2,11 +2,15 @@
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApi;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
@@ -49,125 +53,114 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
?\DateTimeInterface $birthdate = null,
?\DateTimeInterface $birthdateBefore = null,
?\DateTimeInterface $birthdateAfter = null,
string $gender = null,
string $countryCode = null
string $countryCode = null,
string $phonenumber = null,
string $city = null
): array {
$qb = $this->createSearchQuery($default, $firstname, $lastname,
$query = $this->buildAuthorizedQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode);
$this->addACLClauses($qb, 'p');
$countryCode, $phonenumber, $city);
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();
}
return $this->fetchQueryPerson($query);
}
public function countBySearchCriteria(
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
?\DateTimeInterface $birthdate = null,
?\DateTimeInterface $birthdateBefore = null,
?\DateTimeInterface $birthdateAfter = null,
string $gender = null,
string $countryCode = null
string $countryCode = null,
string $phonenumber = null,
string $city = null
): int {
$qb = $this->createSearchQuery($default, $firstname, $lastname,
$query = $this->buildAuthorizedQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode);
$this->addACLClauses($qb, 'p');
$countryCode, $phonenumber, $city)
;
return $this->getCountQueryResult($qb,'p');
return $this->fetchQueryCount($query);
}
public function fetchQueryCount(SearchApiQuery $query): int
{
$rsm = new Query\ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$nql = $this->em->createNativeQuery($query->buildQuery(true), $rsm);
$nql->setParameters($query->buildParameters(true));
return $nql->getSingleScalarResult();
}
/**
* Helper method to prepare and return the count for search query
*
* 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.
* @return array|Person[]
*/
public function getCountQueryResult(QueryBuilder $qb, $alias): int
public function fetchQueryPerson(SearchApiQuery $query, ?int $start = 0, ?int $limit = 50): array
{
$qb->select('COUNT('.$alias.'.id)');
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Person::class, 'person');
return $qb->getQuery()->getSingleScalarResult();
$query->addSelectClause($rsm->generateSelectClause());
$nql = $this->em->createNativeQuery(
$query->buildQuery()." ORDER BY pertinence DESC OFFSET ? LIMIT ?", $rsm
)->setParameters(\array_merge($query->buildParameters(), [$start, $limit]));
return $nql->getResult();
}
public function findBySimilaritySearch(string $pattern, int $firstResult,
int $maxResult, bool $simplify = false)
{
$qb = $this->createSimilarityQuery($pattern);
$this->addACLClauses($qb, 'sp');
public function buildAuthorizedQuery(
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTimeInterface $birthdate = null,
?\DateTimeInterface $birthdateBefore = null,
?\DateTimeInterface $birthdateAfter = null,
string $gender = null,
string $countryCode = null,
string $phonenumber = null,
string $city = null
): SearchApiQuery {
$query = $this->createSearchQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode, $phonenumber)
;
return $this->getQueryResult($qb, 'sp', $simplify, $maxResult, $firstResult);
return $this->addAuthorizations($query);
}
public function countBySimilaritySearch(string $pattern)
private function addAuthorizations(SearchApiQuery $query): SearchApiQuery
{
$qb = $this->createSimilarityQuery($pattern);
$this->addACLClauses($qb, 'sp');
$authorizedCenters = $this->authorizationHelper
->getReachableCenters($this->security->getUser(), PersonVoter::SEE);
return $this->getCountQueryResult($qb, 'sp');
if ([] === $authorizedCenters) {
return $query->andWhereClause("FALSE = TRUE", []);
}
return $query
->andWhereClause(
strtr(
"person.center_id IN ({{ center_ids }})",
[
'{{ center_ids }}' => \implode(', ',
\array_fill(0, count($authorizedCenters), '?')),
]
),
\array_map(function(Center $c) {return $c->getId();}, $authorizedCenters)
);
}
/**
* Create a search query without ACL
*
* 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
*/
@@ -175,118 +168,107 @@ final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterfac
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
?\DateTimeInterface $birthdate = null,
?\DateTimeInterface $birthdateBefore = null,
?\DateTimeInterface $birthdateAfter = null,
string $gender = null,
string $countryCode = null
): QueryBuilder {
string $countryCode = null,
string $phonenumber = null,
string $city = null
): SearchApiQuery {
$query = new SearchApiQuery();
$query
->setFromClause("chill_person_person AS person")
;
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');
$pertinence = [];
$pertinenceArgs = [];
$orWhereSearchClause = [];
$orWhereSearchClauseArgs = [];
if (NULL !== $firstname) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname'))
->setParameter('firstname', '%'.$firstname.'%');
}
if ("" !== $default) {
foreach (\explode(" ", $default) as $str) {
$pertinence[] =
"STRICT_WORD_SIMILARITY(LOWER(UNACCENT(?)), person.fullnamecanonical) + ".
"(person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%')::int + ".
"(EXISTS (SELECT 1 FROM unnest(string_to_array(fullnamecanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int";
\array_push($pertinenceArgs, $str, $str, $str);
if (NULL !== $lastname) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname'))
->setParameter('lastname', '%'.$lastname.'%');
$orWhereSearchClause[] =
"(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ".
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
\array_push($orWhereSearchClauseArgs, $str, $str);
}
$query->andWhereClause(\implode(' OR ', $orWhereSearchClause),
$orWhereSearchClauseArgs);
} else {
$pertinence = ["1"];
$pertinenceArgs = [];
}
$query
->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs)
;
if (NULL !== $birthdate) {
$qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate'))
->setParameter('birthdate', $birthdate);
$query->andWhereClause(
"person.birthdate = ?::date",
[$birthdate->format('Y-m-d')]
);
}
if (NULL !== $birthdateAfter) {
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
->setParameter('birthdateafter', $birthdateAfter);
if (NULL !== $firstname) {
$query->andWhereClause(
"UNACCENT(LOWER(person.firstname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
[$firstname]
);
}
if (NULL !== $lastname) {
$query->andWhereClause(
"UNACCENT(LOWER(person.lastname)) LIKE '%' || UNACCENT(LOWER(?)) || '%'",
[$lastname]
);
}
if (NULL !== $birthdateBefore) {
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
->setParameter('birthdatebefore', $birthdateBefore);
$query->andWhereClause(
'p.birthdate < ?::date',
[$birthdateBefore->format('Y-m-d')]
);
}
if (NULL !== $gender) {
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
->setParameter('gender', $gender);
if (NULL !== $birthdateAfter) {
$query->andWhereClause(
'p.birthdate > ?::date',
[$birthdateAfter->format('Y-m-d')]
);
}
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 !== $phonenumber) {
$query->andWhereClause(
"person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'"
,
[$phonenumber, $phonenumber, $phonenumber]
);
$query->setFromClause($query->getFromClause()." LEFT JOIN chill_person_phone pp ON pp.person_id = person.id");
}
if (null !== $city) {
$query->setFromClause($query->getFromClause()." ".
"JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id ".
"JOIN chill_main_address cma ON vcpca.address_id = cma.id ".
"JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id");
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.'%');
foreach (\explode(" ", $city) as $cityStr) {
$query->andWhereClause(
"(UNACCENT(LOWER(cmpc.label)) LIKE '%' || UNACCENT(LOWER(?)) || '%' OR cmpc.code LIKE '%' || UNACCENT(LOWER(?)) || '%')",
[$cityStr, $city]
);
}
}
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.'%');
if (null !== $countryCode) {
$query->setFromClause($query->getFromClause()." JOIN country ON person.nationality_id = country.id");
$query->andWhereClause("country.countrycode = UPPER(?)", [$countryCode]);
}
if (null !== $gender) {
$query->andWhereClause("person.gender = ?", [$gender]);
}
return $qb;
return $query;
}
}

View File

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

View File

@@ -0,0 +1,42 @@
<?php
namespace Chill\PersonBundle\Repository\Relationships;
use Chill\PersonBundle\Entity\Relationships\Relation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
class RelationRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Relation::class);
}
public function find($id): ?Relation
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?Relation
{
return $this->findOneBy($criteria);
}
public function getClassName(): string
{
return Relation::class;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Chill\PersonBundle\Repository\Relationships;
use Chill\PersonBundle\Entity\Relationships\Relationship;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectRepository;
class RelationshipRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository(Relationship::class);
}
public function find($id): ?Relationship
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?Relationship
{
return $this->findOneBy($criteria);
}
public function getClassName(): string
{
return Relationship::class;
}
public function findByPerson($personId): array
{
// return all relationships of which person is part? or only where person is the fromPerson?
return $this->repository->createQueryBuilder('r')
->select('r, t') // entity Relationship
->join('r.relation', 't')
->where('r.fromPerson = :val')
->orWhere('r.toPerson = :val')
->setParameter('val', $personId)
->getQuery()
->getResult()
;
}
}

View File

@@ -237,8 +237,13 @@ abbr.referrer { // still used ?
align-self: center; // in flex context
}
.updatedBy {
.updatedBy, .createdBy {
margin-top: 0.3rem;
font-size: 0.9rem;
font-style: italic;
}
.created-updated {
border: 1px solid black;
padding: 10px;
}

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
import vis from 'vis-network/dist/vis-network.min';
require('./scss/vis.scss');
// create an array with nodes
let nodes = new vis.DataSet([
{ id: 1, label: "Node 1" },
{ id: 2, label: "Node 2" },
{ id: 3, label: "Node 3" },
{ id: 4, label: "Node 4" },
{ id: 5, label: "Node 5", cid: 1 },
]);
// create an array with edges
let edges = new vis.DataSet([
{ from: 1, to: 3 },
{ from: 1, to: 2 },
{ from: 2, to: 4 },
{ from: 2, to: 5 },
{ from: 3, to: 3 },
]);
// create a network
let container = document.getElementById("graph-relationship");
let data = {
nodes: nodes,
edges: edges,
};
let options = {};
//
let network = new vis.Network(container, data, options);

View File

@@ -1,5 +0,0 @@
div#graph-relationship {
margin: 2em auto;
height: 500px;
border: 1px solid lightgray;
}

View File

@@ -16,16 +16,15 @@
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
<confirm v-if="accompanyingCourse.step === 'DRAFT'"></confirm>
<div v-for="error in errorMsg" class="vue-component errors alert alert-danger">
<!-- <div v-for="error in errorMsg" v-bind:key="error.id" class="vue-component errors alert alert-danger">
<p>
<span>{{ error.sta }} {{ error.txt }}</span><br>
<span>{{ $t(error.msg) }}</span>
</p>
</div>
</div> -->
</template>
<script>
import { mapState } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import Banner from './components/Banner.vue';
import StickyNav from './components/StickyNav.vue';
import OriginDemand from './components/OriginDemand.vue';
@@ -55,11 +54,12 @@ export default {
Comment,
Confirm,
},
computed: mapState([
'accompanyingCourse',
'addressContext',
'errorMsg'
])
computed: {
...mapState([
'accompanyingCourse',
'addressContext'
]),
},
};
</script>

View File

@@ -86,7 +86,8 @@ const postParticipation = (id, payload, method) => {
})
.then(response => {
if (response.ok) { return response.json(); }
throw { msg: 'Error while sending AccompanyingPeriod Course participation.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
// TODO: adjust message according to status code? Or how to access the message from the violation array?
throw { msg: 'Error while sending AccompanyingPeriod Course participation', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
});
};

View File

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

View File

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

View File

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

View File

@@ -10,24 +10,27 @@
<VueMultiselect
name="selectOrigin"
label="text"
v-bind:custom-label="transText"
:custom-label="transText"
track-by="id"
v-bind:multiple="false"
v-bind:searchable="true"
v-bind:placeholder="$t('origin.placeholder')"
:multiple="false"
:searchable="true"
:placeholder="$t('origin.placeholder')"
v-model="value"
v-bind:options="options"
:options="options"
@select="updateOrigin">
</VueMultiselect>
</div>
<div v-if="!isOriginValid" class="alert alert-warning to-confirm">
{{ $t('origin.not_valid') }}
</div>
</div>
</template>
<script>
import VueMultiselect from 'vue-multiselect';
import { getListOrigins } from '../api';
import { mapState } from 'vuex';
import { mapState, mapGetters } from 'vuex';
export default {
name: 'OriginDemand',
@@ -41,24 +44,27 @@ export default {
...mapState({
value: state => state.accompanyingCourse.origin,
}),
...mapGetters([
'isOriginValid'
])
},
mounted() {
this.getOptions();
},
methods: {
getOptions() {
//console.log('loading origins list');
getListOrigins().then(response => new Promise((resolve, reject) => {
this.options = response.results;
resolve();
}));
},
updateOrigin(value) {
//console.log('value', value);
console.log('value', value);
this.$store.dispatch('updateOrigin', value);
},
transText ({ text }) {
return text.fr //TODO multilang
const parsedText = JSON.parse(text);
return parsedText.fr;
},
}
}

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ const appMessages = {
title: "Origine de la demande",
label: "Origine de la demande",
placeholder: "Renseignez l'origine de la demande",
not_valid: "Indiquez une origine de la demande",
},
persons_associated: {
title: "Usagers concernés",
@@ -52,7 +53,7 @@ const appMessages = {
show_household_number: "Voir le ménage (n° {id})",
show_household: "Voir le ménage",
person_without_household_warning: "Certaines usagers n'appartiennent actuellement à aucun ménage. Renseignez leur appartenance dès que possible.",
update_household: "Modifier l'appartenance",
update_household: "Renseigner l'appartenance",
participation_not_valid: "Sélectionnez ou créez au minimum 1 usager",
},
requestor: {
@@ -124,6 +125,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",
origin_not_valid: "indiquez une origine de la demande",
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 !",

View File

@@ -52,6 +52,9 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
isSocialIssueValid(state) {
return state.accompanyingCourse.socialIssues.length > 0;
},
isOriginValid(state) {
return state.accompanyingCourse.origin !== null;
},
isLocationValid(state) {
return state.accompanyingCourse.location !== null;
},
@@ -64,6 +67,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
if (!getters.isParticipationValid) { keys.push('participation'); }
if (!getters.isLocationValid) { keys.push('location'); }
if (!getters.isSocialIssueValid) { keys.push('socialIssue'); }
if (!getters.isOriginValid) { keys.push('origin'); }
if (!getters.isScopeValid) { keys.push('scopes'); }
//console.log('getter keys', keys);
return keys;
@@ -77,7 +81,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
},
mutations: {
catchError(state, error) {
console.log('### mutation: a new error have been catched and pushed in store !', error);
// console.log('### mutation: a new error have been catched and pushed in store !', error);
state.errorMsg.push(error);
},
removeParticipation(state, participation) {

View File

@@ -26,7 +26,7 @@
</div>
<div v-if="isLoadingSocialActions">
<p>spinner</p>
<i class="fa fa-circle-o-notch fa-spin fa-fw"></i>
</div>
<div v-if="hasSocialActionPicked" id="persons">
@@ -72,7 +72,7 @@
{{ $t('action.save') }}
</button>
<button class="btn btn-save" v-show="isPostingWork" disabled>
{{ $t('Save') }}
{{ $t('action.save') }}
</button>
</li>
</ul>

View File

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

View File

@@ -0,0 +1,511 @@
<template>
<div id="visgraph"></div>
<teleport to="#visgraph-legend">
<div class="post-menu">
<div class="list-group mt-4">
<button type="button" class="list-group-item list-group-item-action btn btn-create" @click="createRelationship">
{{ $t('visgraph.add_link') }}
</button>
<a type="button" class="list-group-item list-group-item-action btn btn-misc" id="exportCanvasBtn" @click="exportCanvasAsImage">
<i class="fa fa-camera fa-fw"></i> {{ $t('visgraph.screenshot') }}
</a>
<button type="button" class="list-group-item list-group-item-action btn btn-light" @click="refreshNetwork">
<i class="fa fa-refresh fa-fw"></i> {{ $t('visgraph.refresh') }}
</button>
</div>
<div v-if="displayHelpMessage" class="alert alert-info mt-3">
{{ $t('visgraph.create_link_help') }}
</div>
<div class="my-4 legend">
<h3>{{ $t('visgraph.Legend') }}</h3>
<div class="list-group">
<label class="list-group-item" v-for="layer in legendLayers">
<input
class="form-check-input me-1"
type="checkbox"
:value="layer.id"
v-model="checkedLayers"
@change="toggleLayer"
/>
{{ layer.label }}
</label>
</div>
</div>
</div>
</teleport>
<teleport to="body">
<modal v-if="modal.showModal" :modalDialogClass="modal.modalDialogClass" @close="modal.showModal = false">
<template v-slot:header>
<h2 class="modal-title">{{ $t(modal.title) }}</h2>
<!-- {{ modal.data.id }} -->
</template>
<template v-slot:body>
<div v-if="modal.action === 'delete'">
<p>{{ $t('visgraph.delete_confirmation_text') }}</p>
</div>
<div v-else>
<form>
<div class="row">
<div class="col-12 text-center">{{ $t('visgraph.between') }}<br>{{ $t('visgraph.and') }}</div>
<div class="col">
<h4>{{ getPerson(modal.data.from).text }}</h4>
<p class="text-start" v-if="relation && relation.title">
<span v-if="reverse">
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.from).text, getPerson(modal.data.to).text, relation.reverseTitle.fr.toLowerCase() ])}}
</span>
<span v-else>
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.from).text, getPerson(modal.data.to).text, relation.title.fr.toLowerCase() ])}}
</span>
</p>
</div>
<div class="col text-end">
<h4>{{ getPerson(modal.data.to).text }}</h4>
<p class="text-end" v-if="relation && relation.title">
<span v-if="reverse">
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.to).text, getPerson(modal.data.from).text, relation.title.fr.toLowerCase() ])}}
</span>
<span v-else>
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.to).text, getPerson(modal.data.from).text, relation.reverseTitle.fr.toLowerCase() ])}}
</span>
</p>
</div>
</div>
<div class="my-3">
<VueMultiselect
id="relation"
label="title"
track-by="id"
:custom-label="customLabel"
:placeholder="$t('visgraph.choose_relation')"
:close-on-select="true"
:multiple="false"
:searchable="true"
:options="relations"
v-model="relation"
:value="relation"
>
</VueMultiselect>
</div>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="reverse"
v-model="reverse"
>
<label class="form-check-label" for="reverse">{{ $t('visgraph.reverse_relation') }}</label>
</div>
</form>
</div>
</template>
<template v-slot:footer>
<button class="btn" :class="modal.button.class" @click="submitRelationship">
{{ $t(modal.button.text)}}
</button>
<button class="btn btn-delete" v-if="modal.action === 'edit'" @click="dropRelationship"></button>
</template>
</modal>
</teleport>
</template>
<script>
import vis from 'vis-network/dist/vis-network'
import { mapState, mapGetters } from "vuex"
import Modal from 'ChillMainAssets/vuejs/_components/Modal'
import VueMultiselect from 'vue-multiselect'
import { getRelationsList, postRelationship, patchRelationship, deleteRelationship } from "./api";
import { splitId } from "./vis-network";
export default {
name: "App",
components: {
Modal,
VueMultiselect
},
data() {
return {
container: '',
checkedLayers: [],
relations: [],
displayHelpMessage: false,
listenPersonFlag: 'normal',
newEdgeData: {},
modal: {
showModal: false,
modalDialogClass: "modal-md",
title: null,
action: null,
data: {
type: 'relationship',
from: null,
to: null,
relation: null,
reverse: false
},
button: {
class: null,
text: null
},
}
}
},
computed: {
...mapGetters(['nodes', 'edges',
// not used 'isInWhitelist', 'isHouseholdLoading', 'isCourseLoaded', 'isRelationshipLoaded', 'isPersonLoaded', 'isExcludedNode', 'countLinksByNode', 'getParticipationsByCourse', 'getMembersByHousehold', 'getPersonsGroup',
]),
...mapState(['persons', 'households', 'courses', 'excludedNodesIds', 'updateHack',
// not used 'links', 'relationships', 'whitelistIds', 'personLoadedIds', 'householdLoadingIds', 'courseLoadedIds', 'relationshipLoadedIds',
]),
visgraph_data() {
console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges')
return {
nodes: this.nodes,
edges: this.edges
}
},
refreshNetwork() {
console.log('--- refresh network')
window.network.setData(this.visgraph_data)
},
legendLayers() {
console.log('--- refresh legend and rebuild checked Layers')
this.checkedLayers = []
let layersDisplayed = [
...this.nodes.filter(n => n.id.startsWith('household')),
...this.nodes.filter(n => n.id.startsWith('accompanying'))
]
layersDisplayed.forEach(layer => {
this.checkedLayers.push(layer.id)
})
return [
...this.households,
...this.courses
]
},
checkedLayers() { // required to refresh data checkedLayers
console.log('--- checkedLayers')
return this.checkedLayers
},
relation: {
get() {
return this.modal.data.relation
},
set(value) {
this.modal.data.relation = value
}
},
reverse: {
get() {
return this.modal.data.reverse
},
set(value) {
this.modal.data.reverse = value
}
},
},
watch: {
updateHack(newValue, oldValue) {
console.log(`--- updateHack ${oldValue} <> ${newValue}`)
if (oldValue !== newValue) {
this.forceUpdateComponent()
}
}
},
mounted() {
//console.log('=== mounted: init graph')
this.initGraph()
this.listenOnGraph()
this.getRelationsList()
},
methods: {
initGraph() {
this.container = document.getElementById('visgraph')
// Instanciate vis objects in separate window variables, see vis-network.js
window.network = new vis.Network(this.container, this.visgraph_data, window.options)
},
forceUpdateComponent() {
//console.log('!! forceUpdateComponent !!')
this.refreshNetwork
this.$forceUpdate()
},
// events
listenOnGraph() {
window.network.on('selectNode', (data) => {
if (data.nodes.length > 1) {
throw 'Multi selection is not allowed. Disable it in options.interaction !'
}
let node = data.nodes[0]
let nodeType = splitId(node, 'type')
switch (nodeType) {
case 'person':
let person = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', person.id)
if (this.listenPersonFlag === 'normal') {
if (person.folded === true) {
console.log(' @@> expand mode event')
this.$store.commit('unfoldPerson', person)
}
} else {
console.log(' @@> create link mode event')
this.listenStepsToAddRelationship(person)
}
break
case 'household':
let household = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', household.id)
this.$store.dispatch('unfoldPersonsByHousehold', household)
break
case 'accompanying_period':
let course = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', course.id)
this.$store.dispatch('unfoldPersonsByCourse', course)
break
default:
throw 'event is undefined for this type of node'
}
this.forceUpdateComponent()
})
window.network.on('selectEdge', (data) => {
if (data.nodes.length !== 0 || data.edges.length !== 1) {
return false //we don't want to trigger nodeEdge or multiselect !
}
let link = data.edges[0]
let linkType = splitId(link, 'link')
console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data)
if (linkType.startsWith('relationship')) {
//console.log('linkType relationship')
let relationships = this.edges.filter(l => l.id === link)
if (relationships.length > 1) {
throw 'error: only one link is allowed between two person!'
}
let relationship = relationships[0]
//console.log(relationship)
this.editRelationshipModal({
from: relationship.from,
to: relationship.to,
id: relationship.id,
relation: relationship.relation,
reverse: relationship.reverse
})
}
})
},
listenStepsToAddRelationship(person) {
console.log(' @@> listenStep', this.listenPersonFlag)
if (this.listenPersonFlag === 'step2') {
//console.log(' @@> person 2', person)
this.newEdgeData.to = person.id
this.addRelationshipModal(this.newEdgeData)
this.displayHelpMessage = false
this.listenPersonFlag = 'normal'
this.newEdgeData = {}
}
if (this.listenPersonFlag === 'step1') {
//console.log(' @@> person 1', person)
this.newEdgeData.from = person.id
this.listenPersonFlag = 'step2'
}
},
/// control Layers
toggleLayer(value) {
let id = value.target.value
console.log('@@@@@@ toggle Layer', id)
this.forceUpdateComponent()
if (this.checkedLayers.includes(id)) {
this.removeLayer(id)
} else {
this.addLayer(id)
}
},
addLayer(id) {
//console.log('+ addLayer', id)
this.checkedLayers.push(id)
this.$store.dispatch('excludedNode', ['remove', id])
},
removeLayer(id) {
//console.log('- removeLayer', id)
this.checkedLayers = this.checkedLayers.filter(i => i !== id)
this.$store.dispatch('excludedNode', ['add', id])
},
/// control Modal
addRelationshipModal(edgeData) {
//console.log('==- addRelationshipModal', edgeData)
this.modal = {
data: { from: edgeData.from, to: edgeData.to, reverse: false },
action: 'create',
showModal: true,
title: 'visgraph.add_relationship_link',
button: { class: 'btn-create', text: 'action.create' }
}
},
editRelationshipModal(edgeData) {
//console.log('==- editRelationshipModal', edgeData)
this.modal = {
data: edgeData,
action: 'edit',
showModal: true,
title: 'visgraph.edit_relationship_link',
button: { class: 'btn-edit', text: 'action.edit' }
}
},
// form
resetForm() {
this.modal = {
data: { type: 'relationship', from: null, to: null, relation: null, reverse: false },
action: null,
title: null,
button: { class: null, text: null, }
}
console.log('==- reset Form', this.modal.data)
},
getRelationsList() {
//console.log('fetch relationsList')
return getRelationsList().then(relations => new Promise(resolve => {
//console.log('+ relations list', relations.results.length)
this.relations = relations.results.filter(r => r.isActive === true)
resolve()
})).catch()
},
customLabel(value) {
//console.log('customLabel', value)
return (value.title && value.reverseTitle) ? `${value.title.fr}${value.reverseTitle.fr}` : ''
},
getPerson(id) {
let person = this.persons.filter(p => p.id === id)
return person[0]
},
// actions
createRelationship() {
this.displayHelpMessage = true
this.listenPersonFlag = 'step1' // toggle listener in create link mode
console.log(' @@> switch listener to create link mode:', this.listenPersonFlag)
},
dropRelationship() {
//console.log('delete', this.modal.data)
deleteRelationship(this.modal.data)
.catch()
this.$store.commit('removeLink', this.modal.data.id)
this.modal.showModal = false
this.resetForm()
this.forceUpdateComponent()
},
submitRelationship() {
console.log('submitRelationship', this.modal.action)
switch (this.modal.action) {
case 'create':
return postRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('post relationship response', relationship)
this.$store.dispatch('addLinkFromRelationship', relationship)
this.modal.showModal = false
this.resetForm()
this.forceUpdateComponent()
resolve()
}))
.catch()
case 'edit':
return patchRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('patch relationship response', relationship)
this.$store.commit('updateLink', relationship)
this.modal.showModal = false
this.resetForm()
this.forceUpdateComponent()
resolve()
}))
.catch()
default:
throw "uncaught action"
}
},
// export image
exportCanvasAsImage() {
const canvas = document.getElementById('visgraph')
.querySelector('canvas')
console.log(canvas)
let link = document.getElementById('exportCanvasBtn')
link.download = "filiation.png"
canvas.toBlob(blob => {
console.log(blob)
link.href = URL.createObjectURL(blob)
}, 'image/png')
/*
TODO improve feature
// 1. fonctionne, mais pas de contrôle sur le nom
if (canvas && canvas.getContext('2d')) {
let img = canvas.toDataURL('image/png;base64;')
img = img.replace('image/png','image/octet-stream')
window.open(img, '', 'width=1000, height=1000')
}
// 2. fonctionne, mais 2 click et pas compatible avec tous les browsers
let link = document.getElementById('exportCanvasBtn')
link.download = "image.png"
canvas.toBlob(blob => {
link.href = URL.createObjectURL(blob)
}, 'image/png')
*/
}
}
}
</script>
<style src="vis-network/dist/dist/vis-network.min.css"></style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<style lang="scss" scoped>
div#visgraph {
height: 700px;
margin: auto;
}
div#visgraph-legend {
div.post-menu.legend {
}
}
.modal-mask {
background-color: rgba(0, 0, 0, 0.25);
}
.debug {
margin: 1em; padding: 1em;
color: dimgray;
font-style: italic;
font-size: 80%;
}
</style>

View File

@@ -0,0 +1,195 @@
import { splitId } from './vis-network'
/**
* @function makeFetch
* @param method
* @param url
* @param body
* @returns {Promise<Response>}
*/
const makeFetch = (method, url, body) => {
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: (body !== null) ? JSON.stringify(body) : null
})
.then(response => {
if (response.ok) {
return response.json();
}
if (response.status === 422) {
return response.json().then(violations => {
throw ValidationException(violations)
});
}
throw {
msg: 'Error while updating AccompanyingPeriod Course.',
sta: response.status,
txt: response.statusText,
err: new Error(),
body: response.body
};
});
}
/**
* @param violations
* @constructor
*/
const ValidationException = (violations) => {
this.violations = violations
this.name = 'ValidationException'
}
/**
* @function getFetch
* @param url
* @returns {Promise<Response>}
*/
const getFetch = (url) => {
return makeFetch('GET', url, null)
}
/**
* @function postFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const postFetch = (url, body) => {
return makeFetch('POST', url, body)
}
/**
* @function patchFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const patchFetch = (url, body) => {
return makeFetch('PATCH', url, body)
}
/**
* @function deleteFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const deleteFetch = (url, body) => {
return makeFetch('DELETE', url, null)
}
/**
* @function getHouseholdByPerson
* @param person
* @returns {Promise<Response>}
*/
const getHouseholdByPerson = (person) => {
//console.log('getHouseholdByPerson', person.id)
if (person.current_household_id === null) {
throw 'Currently the person has not household!'
}
return getFetch(
`/api/1.0/person/household/${person.current_household_id}.json`)
}
/**
* @function getCoursesByPerson
* @param person
* @returns {Promise<Response>}
*/
const getCoursesByPerson = (person) => {
//console.log('getCoursesByPerson', person._id)
return getFetch(
`/api/1.0/person/accompanying-course/by-person/${person._id}.json`)
}
/**
* @function getRelationshipsByPerson
* @param person
* @returns {Promise<Response>}
*/
const getRelationshipsByPerson = (person) => {
//console.log('getRelationshipsByPerson', person.id)
return getFetch(
`/api/1.0/relations/relationship/by-person/${person._id}.json`)
}
/**
* Return list of relations
* @returns {Promise<Response>}
*/
const getRelationsList = () => {
return getFetch(`/api/1.0/relations/relation.json`)
}
/**
* @function postRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const postRelationship = (relationship) => {
//console.log(relationship)
return postFetch(
`/api/1.0/relations/relationship.json`,
{
type: 'relationship',
fromPerson: { type: 'person', id: splitId(relationship.from, 'id') },
toPerson: { type: 'person', id: splitId(relationship.to, 'id') },
relation: { type: 'relation', id: relationship.relation.id },
reverse: relationship.reverse
}
)
}
/**
* @function patchRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const patchRelationship = (relationship) => {
//console.log(relationship)
let linkType = splitId(relationship.id, 'link')
let id = splitId(linkType, 'id')
return patchFetch(
`/api/1.0/relations/relationship/${id}.json`,
{
type: 'relationship',
fromPerson: { type: 'person', id: splitId(relationship.from, 'id') },
toPerson: { type: 'person', id: splitId(relationship.to, 'id') },
relation: { type: 'relation', id: relationship.relation.id },
reverse: relationship.reverse
}
)
}
/**
* @function deleteRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const deleteRelationship = (relationship) => {
//console.log(relationship)
let linkType = splitId(relationship.id, 'link')
let id = splitId(linkType, 'id')
return deleteFetch(
`/api/1.0/relations/relationship/${id}.json`
)
}
export {
getHouseholdByPerson,
getCoursesByPerson,
getRelationshipsByPerson,
getRelationsList,
postRelationship,
patchRelationship,
deleteRelationship
}

View File

@@ -0,0 +1,62 @@
const visMessages = {
fr: {
visgraph: {
Course: 'Parcours',
Household: 'Ménage',
Holder: 'Titulaire',
Legend: 'Calques',
concerned: 'concerné',
both: 'neutre, non binaire',
woman: 'féminin',
man: 'masculin',
years: 'ans',
click_to_expand: 'cliquez pour étendre',
add_relationship_link: "Créer un lien de filiation",
edit_relationship_link: "Modifier le lien de filiation",
delete_relationship_link: "Êtes-vous sûr ?",
delete_confirmation_text: "Vous allez supprimer le lien entre ces 2 usagers.",
reverse_relation: "Inverser la relation",
relation_from_to_like: "{2} de {1}", // disable {0}
between: "entre",
and: "et",
add_link: "Créer un lien de filiation",
create_link_help: "Pour créer un lien de filiation, cliquez d'abord sur un usager, puis sur un second ; précisez ensuite la nature du lien dans le formulaire d'édition.",
refresh: "Rafraîchir",
screenshot: "Prendre une photo",
choose_relation: "Choisissez le lien de parenté",
},
edit: 'Éditer',
del: 'Supprimer',
back: 'Revenir en arrière',
addNode: 'Ajouter un noeuds',
addEdge: 'Ajouter un lien de filiation',
editNode: 'Éditer le noeuds',
editEdge: 'Éditer le lien',
addDescription: 'Cliquez dans un espace vide pour créer un nouveau nœud.',
edgeDescription: 'Cliquez sur un usager et faites glisser le lien vers un autre usager pour les connecter.',
editEdgeDescription: 'Cliquez sur les points de contrôle et faites-les glisser vers un nœud pour les relier.',
createEdgeError: 'Il est impossible de relier des arêtes à un cluster.',
deleteClusterError: 'Les clusters ne peuvent pas être supprimés.',
editClusterError: 'Les clusters ne peuvent pas être modifiés.'
},
en: {
edit: 'Edit',
del: 'Delete selected',
back: 'Back',
addNode: 'Add Node',
addEdge: 'Add Link',
editNode: 'Edit Switch',
editEdge: 'Edit Link',
addDescription: 'Click in an empty space to place a new node.',
edgeDescription: 'Click on a node and drag the link to another node to connect them.',
editEdgeDescription: 'Click on the control points and drag them to a node to connect to it.',
createEdgeError: 'Cannot link edges to a cluster.',
deleteClusterError: 'Clusters cannot be deleted.',
editClusterError: 'Clusters cannot be edited.'
}
}
export {
visMessages
}

View File

@@ -0,0 +1,24 @@
import { createApp } from "vue"
import { store } from "./store.js"
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'
import { visMessages } from './i18n'
import App from './App.vue'
import './vis-network'
const i18n = _createI18n(visMessages)
const container = document.getElementById('relationship-graph')
const persons = JSON.parse(container.dataset.persons)
persons.forEach(person => {
store.dispatch('addPerson', person)
store.commit('markInWhitelist', person)
})
const app = createApp({
template: `<app></app>`
})
.use(store)
.use(i18n)
.component('app', App)
.mount('#relationship-graph')

View File

@@ -0,0 +1,534 @@
import { createStore } from 'vuex'
import { getHouseholdByPerson, getCoursesByPerson, getRelationshipsByPerson } from './api'
import { getHouseholdLabel, getHouseholdWidth, getRelationshipLabel, getRelationshipTitle, getRelationshipDirection, splitId, getGender, getAge } from './vis-network'
import {visMessages} from "./i18n";
const debug = process.env.NODE_ENV !== 'production'
const store = createStore({
strict: debug,
state: {
persons: [],
households: [],
courses: [],
relationships: [],
links: [],
whitelistIds: [],
personLoadedIds: [],
householdLoadingIds: [],
courseLoadedIds: [],
relationshipLoadedIds: [],
excludedNodesIds: [],
updateHack: 0
},
getters: {
nodes(state) {
let nodes = []
state.persons.forEach(p => {
nodes.push(p)
})
state.households.forEach(h => {
nodes.push(h)
})
state.courses.forEach(c => {
nodes.push(c)
})
// except excluded nodes (unchecked layers)
state.excludedNodesIds.forEach(excluded => {
nodes = nodes.filter(n => n.id !== excluded)
})
return nodes
},
edges(state) {
return state.links
},
isInWhitelist: (state) => (person_id) => {
return state.whitelistIds.includes(person_id)
},
isHouseholdLoading: (state) => (household_id) => {
return state.householdLoadingIds.includes(household_id)
},
isCourseLoaded: (state) => (course_id) => {
return state.courseLoadedIds.includes(course_id)
},
isRelationshipLoaded: (state) => (relationship_id) => {
return state.relationshipLoadedIds.includes(relationship_id)
},
isPersonLoaded: (state) => (person_id) => {
return state.personLoadedIds.includes(person_id)
},
isExcludedNode: (state) => (id) => {
return state.excludedNodesIds.includes(id)
},
countLinksByNode: (state) => (node_id) => {
let array = []
state.links.filter(link => ! link.id.startsWith('relationship'))
.forEach(link => {
if (link.from === node_id || link.to === node_id) {
if (state.excludedNodesIds.indexOf(splitId(link.id, 'link')) === -1) {
array.push(link)
}
//console.log(link.id, state.excludedNodesIds.indexOf(splitId(link.id, 'link')))
}
})
//console.log('count links', array.length, array.map(i => i.id))
return array.length
},
getParticipationsByCourse: (state) => (course_id) => {
const course = state.courses.filter(c => c.id === course_id)[0]
const currentParticipations = course.participations.filter(p => p.endDate === null)
//console.log('get persons in', course_id, currentParticipations.map(p => p.person.id),
// 'with folded', currentParticipations.filter(p => p.person.folded === true).map(p => p.person.id))
return currentParticipations
},
getMembersByHousehold: (state) => (household_id) => {
const household = state.households.filter(h => h.id === household_id)[0]
const currentMembers = household.members.filter(m => household.current_members_id.includes(m.id))
//console.log('get persons in', household_id, currentMembers.map(m => m.person.id),
// 'with folded', currentMembers.filter(m => m.person.folded === true).map(m => m.person.id))
return currentMembers
},
/**
* This getter is a little bit mysterious :
* The 2 previous getters return complete array, but folded (missing) persons are not taken into consideration and are not displayed (!?!)
* This getter compare input array (participations|members) to personLoadedIds array
* and return complete array with folded persons taken into consideration
*
* @param state
* @param array - An array of persons from course or household.
* This array is dirty, melting persons adapted (or not) to vis, with _id and _label.
* @return array - An array of persons mapped and taken in state.persons
*/
getPersonsGroup: (state) => (array) => {
let group = []
array.forEach(item => {
let id = splitId(item.person.id, 'id')
if (state.personLoadedIds.includes(id)) {
group.push(state.persons.filter(person => person._id === id)[0])
}
})
//console.log('array', array.map(item => item.person.id))
console.log('get persons group', group.map(f => f.id))
return group
},
},
mutations: {
addPerson(state, [person, options]) {
let debug = ''
/// Debug mode: uncomment to display person_id on visgraph
//debug = `\nid ${person.id}`
person.group = person.type
person._id = person.id
person.id = `person_${person.id}`
person.label = `*${person.text}*\n_${getGender(person.gender)} - ${getAge(person.birthdate)}_${debug}` //
person.folded = false
// folded is used for missing persons
if (options.folded) {
person.title = visMessages.fr.visgraph.click_to_expand
person._label = person.label // keep label
person.label = null
person.folded = true
}
state.persons.push(person)
},
addHousehold(state, household) {
household.group = household.type
household._id = household.id
household.label = `${visMessages.fr.visgraph.Household}${household.id}`
household.id = `household_${household.id}`
state.households.push(household)
},
addCourse(state, course) {
course.group = course.type
course._id = course.id
course.label = `${visMessages.fr.visgraph.Course}${course.id}`
course.id = `accompanying_period_${course.id}`
state.courses.push(course)
},
addRelationship(state, relationship) {
relationship.group = relationship.type
relationship._id = relationship.id
relationship.id = `relationship_${relationship.id}`
state.relationships.push(relationship)
},
addLink(state, link) {
state.links.push(link)
},
updateLink(state, link) {
console.log('updateLink', link)
let link_ = {
from: `person_${link.fromPerson.id}`,
to: `person_${link.toPerson.id}`,
id: 'relationship_' + splitId(link.id,'id')
+ '-person_' + link.fromPerson.id + '-person_' + link.toPerson.id,
arrows: getRelationshipDirection(link),
color: 'lightblue',
font: { color: '#33839d' },
dashes: true,
label: getRelationshipLabel(link),
title: getRelationshipTitle(link),
relation: link.relation,
reverse: link.reverse
}
// find row position and replace by updatedLink
state.links.splice(
state.links.findIndex(item => item.id === link_.id), 1, link_
)
},
removeLink(state, link_id) {
state.links = state.links.filter(l => l.id !== link_id)
},
//// id markers
markInWhitelist(state, person) {
state.whitelistIds.push(person.id)
},
markPersonLoaded(state, id) {
state.personLoadedIds.push(id)
},
unmarkPersonLoaded(state, id) {
state.personLoadedIds = state.personLoadedIds.filter(i => i !== id)
},
markHouseholdLoading(state, id) {
//console.log('..loading household', id)
state.householdLoadingIds.push(id)
},
unmarkHouseholdLoading(state, id) {
state.householdLoadingIds = state.householdLoadingIds.filter(i => i !== id)
},
markCourseLoaded(state, id) {
state.courseLoadedIds.push(id)
},
unmarkCourseLoaded(state, id) {
state.courseLoadedIds = state.courseLoadedIds.filter(i => i !== id)
},
markRelationshipLoaded(state, id) {
state.relationshipLoadedIds.push(id)
},
unmarkRelationshipLoaded(state, id) {
state.relationshipLoadedIds = state.relationshipLoadedIds.filter(i => i !== id)
},
//// excluded
addExcludedNode(state, id) {
//console.log('==> exclude list: +', id)
state.excludedNodesIds.push(id)
},
removeExcludedNode(state, id) {
//console.log('<== exclude list: -', id)
state.excludedNodesIds = state.excludedNodesIds.filter(e => e !== id)
},
//// unfold
unfoldPerson(state, person) {
//console.log('unfoldPerson', person)
person.label = person._label
delete person._label
delete person.title
person.folded = false
},
//// force update hack
updateHack(state) {
state.updateHack = state.updateHack + 1
}
},
actions: {
/**
* Expand loop (steps 1->10), always start from a person.
* Fetch household, courses, relationships, and others persons.
* These persons are "missing" and will be first display in fold mode.
*
* 1) Add a new person
* @param object
* @param person
*/
addPerson({ commit, dispatch }, person) {
commit('markPersonLoaded', person.id)
commit('addPerson', [person, { folded: false }])
commit('updateHack')
dispatch('fetchInfoForPerson', person)
},
/**
* 2) Fetch infos for this person (hub)
* @param object
* @param person
*/
fetchInfoForPerson({ dispatch }, person) {
// TODO enfants hors ménages
// example: household 61
// console.log(person.text, 'household', person.current_household_id)
if (null !== person.current_household_id) {
dispatch('fetchHouseholdForPerson', person)
}
dispatch('fetchCoursesByPerson', person)
dispatch('fetchRelationshipByPerson', person)
},
/**
* 3) Fetch person current household (if it is not already loading)
* check first isHouseholdLoading to fetch household once
* @param object
* @param person
*/
fetchHouseholdForPerson({ commit, getters, dispatch }, person) {
//console.log(' isHouseholdLoading ?', getters.isHouseholdLoading(person.current_household_id))
if (! getters.isHouseholdLoading(person.current_household_id)) {
commit('markHouseholdLoading', person.current_household_id)
getHouseholdByPerson(person)
.then(household => new Promise(resolve => {
commit('addHousehold', household)
// DISABLED: in init or expand loop, layer is uncheck when added
//commit('addExcludedNode', household.id)
//commit('updateHack')
dispatch('addLinkFromPersonsToHousehold', household)
commit('updateHack')
resolve()
})
).catch( () => {
commit('unmarkHouseholdLoading', person.current_household_id)
})
}
},
/**
* 4) Add an edge for each household member (household -> person)
* @param object
* @param household
*/
addLinkFromPersonsToHousehold({ commit, getters, dispatch }, household) {
let members = getters.getMembersByHousehold(household.id)
console.log('add link for', members.length, 'members')
members.forEach(m => {
commit('addLink', {
from: `${m.person.type}_${m.person.id}`,
to: `household_${m.person.current_household_id}`,
id: `household_${m.person.current_household_id}-person_${m.person.id}`,
arrows: 'from',
color: 'pink',
font: { color: '#D04A60' },
label: getHouseholdLabel(m),
width: getHouseholdWidth(m),
})
if (!getters.isPersonLoaded(m.person.id)) {
dispatch('addMissingPerson', [m.person, household])
}
})
},
/**
* 5) Fetch AccompanyingCourses for the person
* @param object
* @param person
*/
fetchCoursesByPerson({ commit, dispatch }, person) {
getCoursesByPerson(person)
.then(courses => new Promise(resolve => {
dispatch('addCourses', courses)
resolve()
}))
},
/**
* 6) Add each distinct course (a person can have multiple courses)
* @param object
* @param courses
*/
addCourses({ commit, getters, dispatch }, courses) {
let currentCourses = courses.filter(c => c.closingDate === null)
currentCourses.forEach(course => {
//console.log(' isCourseLoaded ?', getters.isCourseLoaded(course.id))
if (! getters.isCourseLoaded(course.id)) {
commit('markCourseLoaded', course.id)
commit('addCourse', course)
commit('addExcludedNode', course.id) // in init or expand loop, layer is uncheck when added
dispatch('addLinkFromPersonsToCourse', course)
commit('updateHack')
}
})
},
/**
* 7) Add an edge for each course participation (course <- person)
* @param object
* @param course
*/
addLinkFromPersonsToCourse({ commit, getters, dispatch }, course) {
const participations = getters.getParticipationsByCourse(course.id)
console.log('add link for', participations.length, 'participations')
participations.forEach(p => {
//console.log(p.person.id)
commit('addLink', {
from: `${p.person.type}_${p.person.id}`,
to: `${course.id}`,
id: `accompanying_period_${splitId(course.id,'id')}-person_${p.person.id}`,
arrows: 'from',
color: 'orange',
font: { color: 'darkorange' },
})
if (!getters.isPersonLoaded(p.person.id)) {
dispatch('addMissingPerson', [p.person, course])
}
})
},
/**
* 8) Fetch Relationship
* @param object
* @param person
*/
fetchRelationshipByPerson({ dispatch }, person) {
//console.log('fetchRelationshipByPerson', person)
getRelationshipsByPerson(person)
.then(relationships => new Promise(resolve => {
dispatch('addRelationships', relationships)
resolve()
}))
},
/**
* 9) Add each distinct relationship
* @param object
* @param relationships
*/
addRelationships({ commit, getters, dispatch }, relationships) {
relationships.forEach(relationship => {
//console.log(' isRelationshipLoaded ?', getters.isRelationshipLoaded(relationship.id))
if (! getters.isRelationshipLoaded(relationship.id)) {
commit('markRelationshipLoaded', relationship.id)
commit('addRelationship', relationship)
dispatch('addLinkFromRelationship', relationship)
commit('updateHack')
}
})
},
/**
* 10) Add an edge for each relationship (person -> person)
* @param object
* @param relationship
*/
addLinkFromRelationship({ commit, getters, dispatch }, relationship) {
//console.log('-> addLink from person', relationship.fromPerson.id, 'to person', relationship.toPerson.id)
commit('addLink', {
from: `person_${relationship.fromPerson.id}`,
to: `person_${relationship.toPerson.id}`,
id: 'relationship_' + splitId(relationship.id,'id')
+ '-person_' + relationship.fromPerson.id + '-person_' + relationship.toPerson.id,
arrows: getRelationshipDirection(relationship),
color: 'lightblue',
font: { color: '#33839d' },
dashes: true,
label: getRelationshipLabel(relationship),
title: getRelationshipTitle(relationship),
relation: relationship.relation,
reverse: relationship.reverse
})
for (let person of [relationship.fromPerson, relationship.toPerson]) {
if (!getters.isPersonLoaded(person.id)) {
dispatch('addMissingPerson', [person, relationship])
}
}
},
/**
* Add missing person. node is displayed without label (folded).
* We stop here and listen on events to unfold person and expand its fetch infos
* @param object
* @param array
*/
addMissingPerson({ commit, getters, dispatch }, [person, parent]) {
console.log('! add missing Person', person.id)
commit('markPersonLoaded', person.id)
commit('addPerson', [person, { folded: true }])
if (getters.isExcludedNode(parent.id)) {
// in init or expand loop, exclude too missing persons if parent have been excluded
commit('addExcludedNode', person.id)
}
commit('updateHack')
},
/**
* ==================================================================
* Triggered by a vis-network event when clicking on a Course Node.
* Each folded node is unfold, then expanded with fetch infos
* @param object
* @param course
*/
unfoldPersonsByCourse({ getters, commit, dispatch }, course) {
const participations = getters.getParticipationsByCourse(course.id)
getters.getPersonsGroup(participations)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}
})
},
/**
* Triggered by a vis-network event when clicking on a Household Node.
* Each folded node is unfold, then expanded with fetch infos
* @param object
* @param household
*/
unfoldPersonsByHousehold({ getters, commit, dispatch }, household) {
const members = getters.getMembersByHousehold(household.id)
getters.getPersonsGroup(members)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}
})
},
/**
* ==================================================================
* For an excluded node, add|remove relative persons excluded too
* @param object
* @param array (add|remove action, id)
*/
excludedNode({ getters, commit }, [action, id]) {
const personGroup = () => {
switch (splitId(id, 'type')) {
case 'accompanying_period':
return getters.getParticipationsByCourse(id)
case 'household':
return getters.getMembersByHousehold(id)
default:
throw 'undefined case with this id'
}
}
let group = getters.getPersonsGroup(personGroup())
if (action === 'add') {
commit('addExcludedNode', id)
group.forEach(person => {
// countLinks < 2 but parent has just already been added !
if (!getters.isInWhitelist(person.id) && getters.countLinksByNode(person.id) < 1) {
commit('addExcludedNode', person.id)
}
})
}
if (action === 'remove') {
commit('removeExcludedNode', id)
group.forEach(person => {
commit('removeExcludedNode', person.id)
})
}
commit('updateHack')
},
}
})
export { store }

View File

@@ -0,0 +1,262 @@
import { visMessages } from './i18n'
/**
* Vis-network initial data/configuration script
* Notes:
* Use window.network and window.options to avoid conflict between vue and vis
* cfr. https://github.com/almende/vis/issues/2524#issuecomment-307108271
*/
window.network = {}
window.options = {
locale: 'fr',
locales: visMessages,
/*
configure: {
enabled: true,
filter: 'nodes,edges',
//container: undefined,
showButton: true
},
*/
physics: {
enabled: true,
barnesHut: {
theta: 0.5,
gravitationalConstant: -2000,
centralGravity: 0.08, //// 0.3
springLength: 220, //// 95
springConstant: 0.04,
damping: 0.09,
avoidOverlap: 0
},
forceAtlas2Based: {
theta: 0.5,
gravitationalConstant: -50,
centralGravity: 0.01,
springLength: 100,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0
},
repulsion: {
centralGravity: 0.2,
springLength: 200,
springConstant: 0.05,
nodeDistance: 100,
damping: 0.09
},
hierarchicalRepulsion: {
centralGravity: 0.0,
springLength: 100,
springConstant: 0.01,
nodeDistance: 120,
damping: 0.09,
avoidOverlap: 0
},
maxVelocity: 50,
minVelocity: 0.1,
solver: 'forceAtlas2Based', //'barnesHut', //
stabilization: {
enabled: true,
iterations: 1000,
updateInterval: 100,
onlyDynamicEdges: false,
fit: true
},
timestep: 0.5,
adaptiveTimestep: true,
wind: { x: 0, y: 0 }
},
interaction: {
hover: true,
multiselect: true,
navigationButtons: false,
},
manipulation: {
enabled: false,
initiallyActive: false,
addNode: false,
deleteNode: false
},
nodes: {
borderWidth: 1,
borderWidthSelected: 3,
font: {
multi: 'md'
}
},
edges: {
font: {
color: '#b0b0b0',
size: 9,
face: 'arial',
background: 'none',
strokeWidth: 2, // px
strokeColor: '#ffffff',
align: 'middle',
multi: false,
vadjust: 0,
},
scaling:{
label: true,
},
smooth: true,
},
groups: {
person: {
shape: 'box',
shapeProperties: {
borderDashes: false,
borderRadius: 3,
},
color: {
border: '#b0b0b0',
background: 'rgb(193,229,222)',
highlight: {
border: '#89c9a9',
background: 'rgb(156,213,203)'
},
hover: {
border: '#89c9a9',
background: 'rgb(156,213,203)'
}
},
opacity: 0.85,
shadow:{
enabled: true,
color: 'rgba(0,0,0,0.5)',
size:10,
x:5,
y:5
},
},
household: {
color: 'pink'
},
accompanying_period: {
color: 'orange',
},
}
}
/**
* @param gender
* @returns {string}
*/
const getGender = (gender) => {
switch (gender) {
case 'both':
return visMessages.fr.visgraph.both
case 'woman':
return visMessages.fr.visgraph.woman
case 'man':
return visMessages.fr.visgraph.man
default:
throw 'gender undefined'
}
}
/**
* TODO Repeat getAge() in PersonRenderBox.vue
* @param birthdate
* @returns {string|null}
*/
const getAge = (birthdate) => {
if (null === birthdate) {
return null
}
const birthday = new Date(birthdate.datetime)
const now = new Date()
return (now.getFullYear() - birthday.getFullYear()) + ' '+ visMessages.fr.visgraph.years
}
/**
* Return member position in household
* @param member
* @returns string
*/
const getHouseholdLabel = (member) => {
let position = member.position.label.fr
let holder = member.holder ? ` ${visMessages.fr.visgraph.Holder}` : ''
return position + holder
}
/**
* Return edge width for member (depends of position in household)
* @param member
* @returns integer (width)
*/
const getHouseholdWidth = (member) => {
if (member.holder) {
return 5
}
if (member.shareHousehold) {
return 2
}
return 1
}
/**
* Return direction edge
* @param relationship
* @returns string
*/
const getRelationshipDirection = (relationship) => {
return (!relationship.reverse) ? 'to' : 'from'
}
/**
* Return label edge
* !! always set label in title direction (arrow is reversed, see in previous method) !!
* @param relationship
* @returns string
*/
const getRelationshipLabel = (relationship) => {
return relationship.relation.title.fr
}
/**
* Return title edge
* @param relationship
* @returns string
*/
const getRelationshipTitle = (relationship) => {
return (!relationship.reverse) ?
relationship.relation.title.fr + ': ' + relationship.fromPerson.text + '\n' + relationship.relation.reverseTitle.fr + ': ' + relationship.toPerson.text :
relationship.relation.title.fr + ': ' + relationship.toPerson.text + '\n' + relationship.relation.reverseTitle.fr + ': ' + relationship.fromPerson.text
}
/**
* Split string id and return type|id substring
* @param id
* @param position
* @returns string|integer
*/
const splitId = (id, position) => {
//console.log(id, position)
switch (position) {
case 'type': // return 'accompanying_period'
return /(.+)_/.exec(id)[1]
case 'id': // return 124
return parseInt(id.toString()
.split("_")
.pop())
case 'link':
return id.split("-")[0] // return first segment
default:
throw 'position undefined'
}
}
export {
getGender,
getAge,
getHouseholdLabel,
getHouseholdWidth,
getRelationshipDirection,
getRelationshipLabel,
getRelationshipTitle,
splitId
}

View File

@@ -20,18 +20,25 @@
v-bind:item="item">
</suggestion-third-party>
<suggestion-user
v-if="item.result.type === 'user'"
v-bind:item="item">
</suggestion-user>
</div>
</template>
<script>
import SuggestionPerson from './TypePerson';
import SuggestionThirdParty from './TypeThirdParty';
import SuggestionUser from './TypeUser';
export default {
name: 'PersonSuggestion',
components: {
SuggestionPerson,
SuggestionThirdParty,
SuggestionUser,
},
props: [
'item',

View File

@@ -0,0 +1,47 @@
<template>
<div class="container usercontainer">
<div class="user-identification">
<span class="name">
{{ item.result.text }}
</span>
</div>
</div>
<div class="right_actions">
<span class="badge rounded-pill bg-secondary">
{{ $t('user')}}
</span>
</div>
</template>
<script>
const i18n = {
messages: {
fr: {
user: 'Utilisateur' // TODO how to define other translations?
}
}
};
export default {
name: 'SuggestionUser',
props: ['item'],
i18n,
computed: {
hasParent() {
return this.$props.item.result.parent !== null;
},
}
}
</script>
<style lang="scss" scoped>
.usercontainer {
.userparent {
.name {
font-weight: bold;
font-variant: all-small-caps;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_accompanying_period_work_list' %}
{% block title 'accompanying_course_work.remove'|trans %}
{% block content %}
<div class="accompanying_course_work-list">
<h2 class="badge-title">
<span class="title_label">{{ 'accompanying_course_work.action'|trans }}</span>
<span class="title_action">{{ work.socialAction|chill_entity_render_string }}</span>
</h2>
<div>
<h3>{{ "Associated peoples"|trans }}</h3>
<ul>
{% for p in work.persons %}
{{ p|chill_entity_render_box }}
{% endfor %}
</ul>
</div>
</div>
{{ include('@ChillMain/Util/confirmation_template.html.twig',
{
'title' : 'accompanying_course_work.remove'|trans,
'confirm_question' : 'Are you sure you want to remove this work of the accompanying period %name% ?'|trans({ '%name%' : accompanyingCourse.id } ),
'cancel_route' : 'chill_person_accompanying_period_work_list',
'cancel_parameters' : {'id' : accompanyingCourse.id},
'form' : delete_form
} ) }}
{% endblock %}

View File

@@ -103,6 +103,11 @@
href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}"
>{% if buttonText is not defined or buttonText == true %}{{ 'Edit'|trans }}{% endif %}</a>
</li>
<li>
<a class="btn btn-delete" title="{{ 'Delete'|trans }}"
href="{{ path('chill_person_accompanying_period_work_delete', { 'id': w.id } ) }}"
>{% if buttonText is not defined or buttonText == true %}{{ 'Delete'|trans }}{% endif %}</a>
</li>
</ul>
</div>

View File

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

View File

@@ -62,7 +62,7 @@
{%- endif -%}
{%- if options['addId'] -%}
<span class="id-number" title="{{ 'Person'|trans ~ ' n° ' ~ person.id }}">
{{ person.id|upper }}
{{ person.id|upper -}}
</span>
{%- endif -%}
</div>
@@ -95,7 +95,7 @@
</time>
{%- if options['addAge'] -%}
<span class="age">
({{ 'years_old'|trans({ 'age': person.age }) }})
{{- 'years_old'|trans({ 'age': person.age }) -}}
</span>
{%- endif -%}
{%- endif -%}

View File

@@ -2,26 +2,34 @@
{% block title 'household.Relationship'|trans %}
{% block content %}
<h1>{{ block('title') }}</h1>
<div id="graph-relationship"></div>
{#
Give more space to graph:
* use parent twig block (layout_wvm_content)
* hide title (d-none)
* apply negative margin-top
#}
{% block layout_wvm_content %}
<div class="row justify-content-center">
{% for m in household.members %}
{% if m.endDate is null %}
{{ dump(m) }}
{% endif %}
{% endfor %}
<div class="col-md-10 col-xxl d-none">
<h1>{{ block('title') }}</h1>
</div>
<div id="relationship-graph"
style="margin-top: -3rem"
data-persons="{{ persons|e('html_attr') }}">
</div>
</div>
{% endblock %}
{% block block_post_menu %}
<div id="visgraph-legend"></div>
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('page_vis') }}
{{ encore_entry_script_tags('vue_visgraph') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('page_vis') }}
{{ encore_entry_link_tags('vue_visgraph') }}
{% endblock %}
{% block block_post_menu %}{% endblock %}

View File

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

View File

@@ -216,13 +216,23 @@ This view should receive those arguments:
{%- if chill_person.fields.mobilenumber == 'visible' -%}
<dl>
<dt>{{ 'Mobilenumber'|trans }}&nbsp;:</dt>
<dd>{% if person.mobilenumber is not empty %}<a href="tel:{{ person.mobilenumber }}"><pre>{{ person.mobilenumber|chill_format_phonenumber }}</pre></a>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
<dd>{% if person.mobilenumber is not empty %}<a href="tel:{{ person.mobilenumber }}">{{ person.mobilenumber|chill_format_phonenumber }}</a>{% else %}<span class="chill-no-data-statement">{{ 'No data given'|trans }}{% endif %}</dd>
</dl>
{% endif %}
{# TODO
display collection of others phonenumbers
#}
{%- if chill_person.fields.mobilenumber == 'visible' -%}
{% if person.otherPhoneNumbers is not empty %}
<dl>
<dt>{{ 'Others phone numbers'|trans }}&nbsp;:</dt>
{% for el in person.otherPhoneNumbers %}
{% if el.phonenumber is not empty %}
<dd>{% if el.description is not empty %}{{ el.description }}&nbsp;:&nbsp;{% endif %}<a href="tel:{{ el.phonenumber }}">{{ el.phonenumber|chill_format_phonenumber }}</a></dd>
{% endif %}
{% endfor %}
</ul>
</dl>
{% endif %}
{% endif %}
{%- if chill_person.fields.contact_info == 'visible' -%}
<dl>
@@ -259,6 +269,21 @@ This view should receive those arguments:
</div>
{% endif %}
<div class="created-updated">
{% if person.createdBy %}
<div class="createdBy">
{{ 'Created by'|trans}}: <b>{{ person.createdBy|chill_entity_render_box }}</b>,<br>
{{ 'on'|trans ~ person.createdAt|format_datetime('long', 'short') }}
</div>
{% endif %}
{% if person.updatedBy %}
<div class="updatedBy">
{{ 'Last updated by'|trans}}: <b>{{ person.updatedBy|chill_entity_render_box }}</b>,<br>
{{ 'on'|trans ~ person.updatedAt|format_datetime('long', 'short') }}
</div>
{% endif %}
</div>
{% if is_granted('CHILL_PERSON_UPDATE', person) %}
<ul class="sticky-form-buttons record_actions">
<li>

View File

@@ -1,30 +1,19 @@
<?php
/*
*
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\AbstractSearch;
use Chill\MainBundle\Search\Utils\ExtractDateFromPattern;
use Chill\MainBundle\Search\Utils\ExtractPhonenumberFromPattern;
use Chill\PersonBundle\Form\Type\GenderType;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -34,23 +23,29 @@ use Symfony\Component\Templating\EngineInterface;
class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface
{
protected EngineInterface $templating;
protected PaginatorFactory $paginatorFactory;
protected PersonACLAwareRepositoryInterface $personACLAwareRepository;
private EngineInterface $templating;
private PaginatorFactory $paginatorFactory;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private ExtractDateFromPattern $extractDateFromPattern;
private ExtractPhonenumberFromPattern $extractPhonenumberFromPattern;
const NAME = "person_regular";
public const NAME = "person_regular";
private const POSSIBLE_KEYS = [
'_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before',
'birthdate-after', 'gender', 'nationality'
'birthdate-after', 'gender', 'nationality', 'phonenumber', 'city'
];
public function __construct(
EngineInterface $templating,
ExtractDateFromPattern $extractDateFromPattern,
ExtractPhonenumberFromPattern $extractPhonenumberFromPattern,
PaginatorFactory $paginatorFactory,
PersonACLAwareRepositoryInterface $personACLAwareRepository
) {
$this->templating = $templating;
$this->extractDateFromPattern = $extractDateFromPattern;
$this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern;
$this->paginatorFactory = $paginatorFactory;
$this->personACLAwareRepository = $personACLAwareRepository;
}
@@ -84,6 +79,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
*/
public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
{
$terms = $this->findAdditionnalInDefault($terms);
$total = $this->count($terms);
$paginator = $this->paginatorFactory->create($total);
@@ -102,7 +98,9 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'preview' => $options[SearchInterface::SEARCH_PREVIEW_OPTION],
'paginator' => $paginator
));
} elseif ($format === 'json') {
}
if ($format === 'json') {
return [
'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])),
'pagination' => [
@@ -112,15 +110,30 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
}
}
private function findAdditionnalInDefault(array $terms): array
{
// chaining some extractor
$datesResults = $this->extractDateFromPattern->extractDates($terms['_default']);
$phoneResults = $this->extractPhonenumberFromPattern->extractPhonenumber($datesResults->getFilteredSubject());
$terms['_default'] = $phoneResults->getFilteredSubject();
if ($datesResults->hasResult() && (!\array_key_exists('birthdate', $terms)
|| NULL !== $terms['birthdate'])) {
$terms['birthdate'] = $datesResults->getFound()[0]->format('Y-m-d');
}
if ($phoneResults->hasResult() && (!\array_key_exists('phonenumber', $terms)
|| NULL !== $terms['phonenumber'])) {
$terms['phonenumber'] = $phoneResults->getFound()[0];
}
return $terms;
}
/**
*
* @param string $pattern
* @param int $start
* @param int $limit
* @param array $options
* @return Person[]
*/
protected function search(array $terms, $start, $limit, array $options = array())
protected function search(array $terms, int $start, int $limit, array $options = [])
{
[
'_default' => $default,
@@ -131,7 +144,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
'phonenumber' => $phonenumber,
'city' => $city,
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
@@ -158,6 +172,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$birthdateAfter,
$gender,
$countryCode,
$phonenumber,
$city
);
}
@@ -172,7 +188,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
'phonenumber' => $phonenumber,
'city' => $city,
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
@@ -196,6 +213,8 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$birthdateAfter,
$gender,
$countryCode,
$phonenumber,
$city
);
}
@@ -226,13 +245,19 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
'label' => 'Birthdate before',
'required' => false
])
->add('gender', ChoiceType::class, [
'choices' => [
'Man' => Person::MALE_GENDER,
'Woman' => Person::FEMALE_GENDER
],
->add('phonenumber', TelType::class, [
'required' => false,
'label' => 'Part of the phonenumber'
])
->add('gender', GenderType::class, [
'label' => 'Gender',
'required' => false
'required' => false,
'expanded' => false,
'placeholder' => 'All genders'
])
->add('city', TextType::class, [
'required' => false,
'label' => 'City or postal code'
])
;
}
@@ -243,7 +268,7 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$string .= empty($data['_default']) ? '' : $data['_default'].' ';
foreach(['firstname', 'lastname', 'gender'] as $key) {
foreach(['firstname', 'lastname', 'gender', 'phonenumber', 'city'] as $key) {
$string .= empty($data[$key]) ? '' : $key.':'.
// add quote if contains spaces
(strpos($data[$key], ' ') !== false ? '"'.$data[$key].'"': $data[$key])
@@ -263,8 +288,9 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
public function convertTermsToFormData(array $terms)
{
foreach(['firstname', 'lastname', 'gender', '_default']
as $key) {
$data = [];
foreach(['firstname', 'lastname', 'gender', '_default', 'phonenumber', 'city'] as $key) {
$data[$key] = $terms[$key] ?? null;
}
@@ -293,6 +319,4 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
{
return self::NAME;
}
}

View File

@@ -1,133 +0,0 @@
<?php
/*
*
* Copyright (C) 2014-2019, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\AbstractSearch;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Search\SearchInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Templating\EngineInterface;
/**
*
*
*/
class PersonSearchByPhone extends AbstractSearch
{
/**
*
* @var PersonRepository
*/
private $personRepository;
/**
*
* @var TokenStorageInterface
*/
private $tokenStorage;
/**
*
* @var AuthorizationHelper
*/
private $helper;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
/**
*
* @var bool
*/
protected $activeByDefault;
/**
*
* @var Templating
*/
protected $engine;
const NAME = 'phone';
public function __construct(
PersonRepository $personRepository,
TokenStorageInterface $tokenStorage,
AuthorizationHelper $helper,
PaginatorFactory $paginatorFactory,
EngineInterface $engine,
$activeByDefault)
{
$this->personRepository = $personRepository;
$this->tokenStorage = $tokenStorage;
$this->helper = $helper;
$this->paginatorFactory = $paginatorFactory;
$this->engine = $engine;
$this->activeByDefault = $activeByDefault === 'always';
}
public function getOrder(): int
{
return 110;
}
public function isActiveByDefault(): bool
{
return $this->activeByDefault;
}
public function renderResult(array $terms, $start = 0, $limit = 50, $options = array(), $format = 'html')
{
$phonenumber = $terms['_default'];
$centers = $this->helper->getReachableCenters($this->tokenStorage
->getToken()->getUser(), new Role(PersonVoter::SEE));
$total = $this->personRepository
->countByPhone($phonenumber, $centers);
$persons = $this->personRepository
->findByPhone($phonenumber, $centers, $start, $limit)
;
$paginator = $this->paginatorFactory
->create($total);
return $this->engine->render('ChillPersonBundle:Person:list_by_phonenumber.html.twig',
array(
'persons' => $persons,
'pattern' => $this->recomposePattern($terms, array(), $terms['_domain'] ?? self::NAME),
'phonenumber' => $phonenumber,
'total' => $total,
'start' => $start,
'search_name' => self::NAME,
'preview' => $options[SearchInterface::SEARCH_PREVIEW_OPTION],
'paginator' => $paginator
));
}
public function supports($domain, $format): bool
{
return $domain === 'phone' && $format === 'html';
}
}

View File

@@ -2,37 +2,62 @@
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\Utils\ExtractDateFromPattern;
use Chill\MainBundle\Search\Utils\ExtractPhonenumberFromPattern;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Search\SearchApiInterface;
use Symfony\Component\Security\Core\Security;
class SearchPersonApiProvider implements SearchApiInterface
{
private PersonRepository $personRepository;
private Security $security;
private AuthorizationHelperInterface $authorizationHelper;
private ExtractDateFromPattern $extractDateFromPattern;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private ExtractPhonenumberFromPattern $extractPhonenumberFromPattern;
public function __construct(PersonRepository $personRepository)
{
public function __construct(
PersonRepository $personRepository,
PersonACLAwareRepositoryInterface $personACLAwareRepository,
Security $security,
AuthorizationHelperInterface $authorizationHelper,
ExtractDateFromPattern $extractDateFromPattern,
ExtractPhonenumberFromPattern $extractPhonenumberFromPattern
) {
$this->personRepository = $personRepository;
$this->personACLAwareRepository = $personACLAwareRepository;
$this->security = $security;
$this->authorizationHelper = $authorizationHelper;
$this->extractDateFromPattern = $extractDateFromPattern;
$this->extractPhonenumberFromPattern = $extractPhonenumberFromPattern;
}
public function provideQuery(string $pattern, array $parameters): SearchApiQuery
{
$query = new SearchApiQuery();
$query
->setSelectKey("person")
->setSelectJsonbMetadata("jsonb_build_object('id', person.id)")
->setSelectPertinence("GREATEST(".
"STRICT_WORD_SIMILARITY(LOWER(UNACCENT(?)), person.fullnamecanonical), ".
"(person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%')::int".
")", [ $pattern, $pattern ])
->setFromClause("chill_person_person AS person")
->setWhereClauses("LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR ".
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ", [ $pattern, $pattern ])
;
$datesResult = $this->extractDateFromPattern->extractDates($pattern);
$phoneResult = $this->extractPhonenumberFromPattern->extractPhonenumber($datesResult->getFilteredSubject());
$filtered = $phoneResult->getFilteredSubject();
return $query;
return $this->personACLAwareRepository->buildAuthorizedQuery(
$filtered,
null,
null,
count($datesResult->getFound()) > 0 ? $datesResult->getFound()[0] : null,
null,
null,
null,
null,
count($phoneResult->getFound()) > 0 ? $phoneResult->getFound()[0] : null
)
->setSelectKey("person")
->setSelectJsonbMetadata("jsonb_build_object('id', person.id)");
}
public function supportsTypes(string $pattern, array $types, array $parameters): bool
{
return \in_array('person', $types);

View File

@@ -1,114 +0,0 @@
<?php
namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\AbstractSearch;
use Chill\MainBundle\Search\SearchInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Symfony\Component\Templating\EngineInterface;
class SimilarityPersonSearch extends AbstractSearch
{
protected PaginatorFactory $paginatorFactory;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
private EngineInterface $templating;
const NAME = "person_similarity";
public function __construct(
PaginatorFactory $paginatorFactory,
PersonACLAwareRepositoryInterface $personACLAwareRepository,
EngineInterface $templating
) {
$this->paginatorFactory = $paginatorFactory;
$this->personACLAwareRepository = $personACLAwareRepository;
$this->templating = $templating;
}
/*
* (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::getOrder()
*/
public function getOrder()
{
return 200;
}
/*
* (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault()
*/
public function isActiveByDefault()
{
return true;
}
public function supports($domain, $format)
{
return 'person' === $domain;
}
/**
* @param array $terms
* @param int $start
* @param int $limit
* @param array $options
* @param string $format
*/
public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
{
$total = $this->count($terms);
$paginator = $this->paginatorFactory->create($total);
if ($format === 'html') {
if ($total !== 0) {
return $this->templating->render('ChillPersonBundle:Person:list.html.twig',
array(
'persons' => $this->search($terms, $start, $limit, $options),
'pattern' => $this->recomposePattern($terms, array('nationality',
'firstname', 'lastname', 'birthdate', 'gender',
'birthdate-before','birthdate-after'), $terms['_domain']),
'total' => $total,
'start' => $start,
'search_name' => self::NAME,
'preview' => $options[SearchInterface::SEARCH_PREVIEW_OPTION],
'paginator' => $paginator,
'title' => "Similar persons"
));
}
else {
return null;
}
} elseif ($format === 'json') {
return [
'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])),
'pagination' => [
'more' => $paginator->hasNextPage()
]
];
}
}
/**
*
* @param string $pattern
* @param int $start
* @param int $limit
* @param array $options
* @return Person[]
*/
protected function search(array $terms, $start, $limit, array $options = array())
{
return $this->personACLAwareRepository
->findBySimilaritySearch($terms['_default'], $start, $limit, $options['simplify'] ?? false);
}
protected function count(array $terms)
{
return $this->personACLAwareRepository->countBySimilaritySearch($terms['_default']);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Household\Position;
@@ -33,14 +35,14 @@ class MembersEditorNormalizer implements DenormalizerInterface, DenormalizerAwar
// household)
if (NULL === $data['destination']) {
return $this->denormalizeLeave($data, $type, $format, $context);
} else {
return $this->denormalizeMove($data, $type, $format, $context);
}
return $this->denormalizeMove($data, $type, $format, $context);
}
private function performChecks($data): void
{
if (NULL == $data['concerned'] ?? NULL
if (NULL == $data['concerned'] ?? NULL
&& FALSE === ·\is_array('concerned')) {
throw new Exception\UnexpectedValueException("The schema does not have any key 'concerned'");
}
@@ -55,8 +57,8 @@ class MembersEditorNormalizer implements DenormalizerInterface, DenormalizerAwar
$editor = $this->factory->createEditor(null);
foreach ($data['concerned'] as $key => $concerned) {
$person = $this->denormalizer->denormalize($concerned['person'] ?? null, Person::class,
$format, $context);
$person = $this->denormalizer->denormalize($concerned['person'] ?? null, Person::class,
$format, $context);
$startDate = $this->denormalizer->denormalize($concerned['start_date'] ?? null, \DateTimeImmutable::class,
$format, $context);
@@ -80,18 +82,18 @@ class MembersEditorNormalizer implements DenormalizerInterface, DenormalizerAwar
$householdContext = $context;
$householdContext['groups'][] = 'create';
$household = $this->denormalizer->denormalize($data['destination'], Household::class,
$household = $this->denormalizer->denormalize($data['destination'], Household::class,
$format, $householdContext);
if (NULL === $household) {
throw new Exception\InvalidArgumentException("household could not be denormalized. Impossible to process");
throw new Exception\InvalidArgumentException("household could not be denormalized. Impossible to process");
}
$editor = $this->factory->createEditor($household);
foreach ($data['concerned'] as $key => $concerned) {
$person = $this->denormalizer->denormalize($concerned['person'] ?? null, Person::class,
$format, $context);
$person = $this->denormalizer->denormalize($concerned['person'] ?? null, Person::class,
$format, $context);
$position = $this->denormalizer->denormalize($concerned['position'] ?? null, Position::class,
$format, $context);
$startDate = $this->denormalizer->denormalize($concerned['start_date'] ?? null, \DateTimeImmutable::class,
@@ -110,10 +112,10 @@ class MembersEditorNormalizer implements DenormalizerInterface, DenormalizerAwar
"person, position or start_date.");
}
$editor->addMovement($startDate, $person, $position, $holder,
$editor->addMovement($startDate, $person, $position, $holder,
$comment);
}
return $editor;
}

View File

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

View File

@@ -39,7 +39,7 @@ use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
* Serialize a Person entity
*
*/
class PersonNormalizer implements
class PersonJsonNormalizer implements
NormalizerInterface,
NormalizerAwareInterface,
DenormalizerInterface,
@@ -67,7 +67,7 @@ class PersonNormalizer implements
$this->centerResolverDispatcher = $centerResolverDispatcher;
}
public function normalize($person, string $format = null, array $context = array())
public function normalize($person, string $format = null, array $context = [])
{
/** @var Household $household */
$household = $person->getCurrentHousehold();
@@ -86,7 +86,6 @@ class PersonNormalizer implements
'mobilenumber' => $person->getMobilenumber(),
'altNames' => $this->normalizeAltNames($person->getAltNames()),
'gender' => $person->getGender(),
'gender_numeric' => $person->getGenderNumeric(),
'current_household_address' => $this->normalizer->normalize($person->getCurrentHouseholdAddress()),
'current_household_id' => $household ? $this->normalizer->normalize($household->getId()) : null,
];
@@ -106,7 +105,7 @@ class PersonNormalizer implements
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Person;
return $data instanceof Person && $format === 'json';
}
public function denormalize($data, string $type, string $format = null, array $context = [])
@@ -129,23 +128,48 @@ class PersonNormalizer implements
$person = new Person();
}
foreach (['firstName', 'lastName', 'phonenumber', 'mobilenumber', 'gender']
foreach (['firstName', 'lastName', 'phonenumber', 'mobilenumber', 'gender',
'birthdate', 'deathdate', 'center']
as $item) {
if (\array_key_exists($item, $data)) {
$person->{'set'.\ucfirst($item)}($data[$item]);
}
}
foreach ([
'birthdate' => \DateTime::class,
'deathdate' => \DateTime::class,
'center' => Center::class
] as $item => $class) {
if (\array_key_exists($item, $data)) {
$object = $this->denormalizer->denormalize($data[$item], $class, $format, $context);
if ($object instanceof $class) {
$person->{'set'.\ucfirst($item)}($object);
}
if (!\array_key_exists($item, $data)) {
continue;
}
switch ($item) {
case 'firstName':
$person->setFirstName($data[$item]);
break;
case 'lastName':
$person->setLastName($data[$item]);
break;
case 'phonenumber':
$person->setPhonenumber($data[$item]);
break;
case 'mobilenumber':
$person->setMobilenumber($data[$item]);
break;
case 'gender':
$person->setGender($data[$item]);
break;
case 'birthdate':
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
if ($object instanceof \DateTime) {
$person->setBirthdate($object);
}
break;
case 'deathdate':
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
if ($object instanceof \DateTime) {
$person->setDeathdate($object);
}
break;
case 'center':
$object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context);
$person->setCenter($object);
break;
default:
throw new \LogicException("item not defined: $item");
}
}

View File

@@ -274,6 +274,8 @@ final class SocialWorkMetadata implements SocialWorkMetadataInterface
$jsonCriterias
);
$entity = null;
switch (true) {
case count($results) === 0:
$entity = $repository->getClassName();
@@ -291,6 +293,10 @@ final class SocialWorkMetadata implements SocialWorkMetadataInterface
);
}
if (null === $entity) {
throw new Exception('Unable to create entity.');
}
return $entity;
}

View File

@@ -38,7 +38,7 @@ class SocialActionRender implements ChillEntityRenderInterface
{
/** @var $socialAction SocialAction */
$options = \array_merge(self::DEFAULT_ARGS, $options);
$titles[] = $this->translatableStringHelper->localize($socialAction->getTitle());
$titles = [$this->translatableStringHelper->localize($socialAction->getTitle())];
while ($socialAction->hasParent()) {
$socialAction = $socialAction->getParent();

View File

@@ -38,8 +38,7 @@ final class SocialIssueRender implements ChillEntityRenderInterface
/** @var $socialIssue SocialIssue */
$options = array_merge(self::DEFAULT_ARGS, $options);
$titles[] = $this->translatableStringHelper
->localize($socialIssue->getTitle());
$titles = [$this->translatableStringHelper->localize($socialIssue->getTitle())];
// loop to parent, until root
while ($socialIssue->hasParent()) {

View File

@@ -259,7 +259,8 @@ class PersonControllerUpdateTest extends WebTestCase
return array(
['firstName', 'random Value', function(Person $person) { return $person->getFirstName(); } ],
['lastName' , 'random Value', function(Person $person) { return $person->getLastName(); } ],
['placeOfBirth', 'none place', function(Person $person) { return $person->getPlaceOfBirth(); }],
// reminder: this value is capitalized
['placeOfBirth', 'A PLACE', function(Person $person) { return $person->getPlaceOfBirth(); }],
['birthdate', '1980-12-15', function(Person $person) { return $person->getBirthdate()->format('Y-m-d'); }],
['phonenumber', '+32123456789', function(Person $person) { return $person->getPhonenumber(); }],
['memo', 'jfkdlmq jkfldmsq jkmfdsq', function(Person $person) { return $person->getMemo(); }],

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Tests\Controller;
use Chill\MainBundle\Test\PrepareClientTrait;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Relationships\Relation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Component\HttpFoundation\Request;
use Chill\PersonBundle\Repository\PersonRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class RelationshipApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
private KernelBrowser $client;
/**
* A cache for all relations
* @var array|null|Relation[]
*/
private ?array $relations = null;
public function setUp()
{
static::bootKernel();
$this->client = $this->getClientAuthenticated();
}
/**
* @dataProvider personProvider
*/
public function testGetRelationshipByPerson($personId)
{
$this->client->request(Request::METHOD_GET, sprintf('/api/1.0/relations/relationship/by-person/%d.json', $personId));
$response = $this->client->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Test to see that API response returns a status code 200');
}
/**
* @dataProvider relationProvider
*/
public function testPostRelationship($fromPersonId, $toPersonId, $relationId, $isReverse): void
{
$this->client->request(Request::METHOD_POST,
'/api/1.0/relations/relationship.json',
[],
[],
[],
\json_encode([
'type' => 'relationship',
'fromPerson' => ['id' => $fromPersonId, 'type' => 'person'],
'toPerson' => ['id' => $toPersonId, 'type' => 'person'],
'relation' => ['id' => $relationId, 'type' => 'relation'],
'reverse' => $isReverse
]));
$response = $this->client->getResponse();
$this->assertEquals(200, $response->getStatusCode());
}
public function relationProvider(): array
{
static::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$countPersons = $em->createQueryBuilder()
->select('count(p)')
->from(Person::class, 'p')
->join('p.center', 'c')
->where('c.name LIKE :name')
->setParameter('name', 'Center A')
->getQuery()
->getSingleScalarResult()
;
$persons = $em->createQueryBuilder()
->select('p')
->from(Person::class, 'p')
->join('p.center', 'c')
->where('c.name LIKE :name')
->setParameter('name', 'Center A')
->getQuery()
->setMaxResults(2)
->setFirstResult(\random_int(0, $countPersons - 1))
->getResult()
;
return [
[$persons[0]->getId(), $persons[1]->getId(), $this->getRandomRelation($em)->getId(), true],
];
}
private function getRandomRelation(EntityManagerInterface $em): Relation
{
if (null === $this->relations) {
$this->relations = $em->getRepository(Relation::class)
->findAll();
}
return $this->relations[\array_rand($this->relations)];
}
public function personProvider(): array
{
static::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$countPersons = $em->createQueryBuilder()
->select('count(p)')
->from(Person::class, 'p')
->join('p.center', 'c')
->where('c.name LIKE :name')
->setParameter('name', 'Center A')
->getQuery()
->getSingleScalarResult()
;
$person = $em->createQueryBuilder()
->select('p')
->from(Person::class, 'p')
->join('p.center', 'c')
->where('c.name LIKE :name')
->setParameter('name', 'Center A')
->getQuery()
->setMaxResults(1)
->setFirstResult(\random_int(0, $countPersons - 1))
->getSingleResult()
;
return [
[$person->getId()],
];
}
}

View File

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

View File

@@ -0,0 +1,28 @@
<?php
namespace Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class PersonJsonNormalizerTest extends KernelTestCase
{
private NormalizerInterface $normalizer;
protected function setUp()
{
self::bootKernel();
$this->normalizer = self::$container->get(NormalizerInterface::class);
}
public function testNormalization()
{
$person = new Person();
$result = $this->normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => [ 'read' ]]);
$this->assertIsArray($result);
}
}

View File

@@ -1,14 +1,18 @@
<?php
declare(strict_types=1);
namespace Validator\Person;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
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;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class PersonHasCenterValidatorTest extends \Symfony\Component\Validator\Test\ConstraintValidatorTestCase
class PersonHasCenterValidatorTest extends ConstraintValidatorTestCase
{
public function testValidateRequired()
{
@@ -42,9 +46,10 @@ class PersonHasCenterValidatorTest extends \Symfony\Component\Validator\Test\Con
'validation' => [
'center_required' => true
]
])
;
]);
return new PersonHasCenterValidator($parameterBag);
$centerResolverDispatcher = $this->createMock(CenterResolverDispatcher::class);
return new PersonHasCenterValidator($parameterBag, $centerResolverDispatcher);
}
}

View File

@@ -11,7 +11,7 @@ class LocationValidity extends Constraint
{
public $messagePersonLocatedMustBeAssociated = "The person where the course is located must be associated to the course. Change course's location before removing the person.";
public $messagePeriodMustRemainsLocated = "The period must remains located";
public $messagePeriodMustRemainsLocated = "The period must remain located";
public function getTargets()
{

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class ParticipationOverlap extends Constraint
{
public $message = 'This participation already exists.';
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Chill\MainBundle\Util\DateRangeCovering;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ParticipationOverlap;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class ParticipationOverlapValidator extends ConstraintValidator
{
private const MAX_PARTICIPATION = 1;
public function validate($participations, Constraint $constraint)
{
if (!$constraint instanceof ParticipationOverlap) {
throw new UnexpectedTypeException($constraint, ParticipationOverlap::class);
}
if (!$participations instanceof Collection) {
throw new UnexpectedTypeException($participations, 'This should be a collection');
}
if (count($participations) <= self::MAX_PARTICIPATION) {
return;
}
$overlaps = new DateRangeCovering(self::MAX_PARTICIPATION, $participations[0]->getStartDate()->getTimezone());
$participationList = [];
foreach ($participations as $participation) {
if (!$participation instanceof AccompanyingPeriodParticipation) {
throw new UnexpectedTypeException($participation, AccompanyingPeriodParticipation::class);
}
$personId = $participation->getPerson()->getId();
$participationList[$personId][] = $participation;
}
foreach ($participationList as $group) {
if (count($group) > 1) {
foreach ($group as $p) {
$overlaps->add($p->getStartDate(), $p->getEndDate(), $p->getId());
}
}
}
$overlaps->compute();
if ($overlaps->hasIntersections()) {
foreach ($overlaps->getIntersections() as list($start, $end, $ids)) {
$msg = $end === null ? $constraint->message :
$constraint->message;
$this->context->buildViolation($msg)
->setParameters([
'{{ start }}' => $start->format('d-m-Y'),
'{{ end }}' => $end === null ? null : $end->format('d-m-Y'),
'{{ ids }}' => $ids,
])
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class ResourceDuplicateCheck extends Constraint
{
public $message = '{{ name }} is already associated to this accompanying course.';
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ResourceDuplicateCheck;
use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender;
class ResourceDuplicateCheckValidator extends ConstraintValidator
{
private PersonRender $personRender;
private ThirdPartyRender $thirdpartyRender;
public function __construct(PersonRender $personRender, ThirdPartyRender $thirdPartyRender)
{
$this->personRender = $personRender;
$this->thirdpartyRender = $thirdPartyRender;
}
public function validate($resources, Constraint $constraint)
{
if (!$constraint instanceof ResourceDuplicateCheck) {
throw new UnexpectedTypeException($constraint, ParticipationOverlap::class);
}
if (!$resources instanceof Collection) {
throw new UnexpectedTypeException($resources, Collection::class);
}
$resourceList = [];
foreach ($resources as $resource) {
$id = ($resource->getResource() instanceof Person ? 'p' :
't').$resource->getResource()->getId();
if (\in_array($id, $resourceList, true)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ name }}', $resource->getResource() instanceof Person ? $this->personRender->renderString($resource->getResource(), []) :
$this->thirdpartyRender->renderString($resource->getResource(), []))
->addViolation();
}
$resourceList[] = $id;
}
}
}

View File

@@ -2,6 +2,7 @@
namespace Chill\PersonBundle\Validator\Constraints\Person;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Validator\Constraint;
@@ -10,10 +11,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class PersonHasCenterValidator extends \Symfony\Component\Validator\ConstraintValidator
{
private bool $centerRequired;
private CenterResolverDispatcher $centerResolverDispatcher;
public function __construct(ParameterBagInterface $parameterBag)
public function __construct(ParameterBagInterface $parameterBag, CenterResolverDispatcher $centerResolverDispatcher)
{
$this->centerRequired = $parameterBag->get('chill_person')['validation']['center_required'];
$this->centerResolverDispatcher = $centerResolverDispatcher;
}
/**
@@ -29,7 +32,7 @@ class PersonHasCenterValidator extends \Symfony\Component\Validator\ConstraintVa
return;
}
if (NULL === $person->getCenter()) {
if (NULL === $this->centerResolverDispatcher->resolveCenter($person)) {
$this
->context
->buildViolation($constraint->message)

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