diff --git a/CHANGELOG.md b/CHANGELOG.md index c53bd97e4..22639a120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,14 @@ and this project adheres to * [person] add validator for accompanying period with a test on social issues (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/76) +* [main] address: use search API end points for getting postal code and reference address (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316) +* [main] address: in edit mode, select the encoded values in multiselect for address reference and city (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316) * [person search] fix bug when using birthdate after and birthdate before * [person search] increase pertinence when lastname begins with search pattern +* [household] field to edit wheter person is titulaire of household or not removed (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/322) +* [activity] create work if a work with same social action is not associated to the activity +* [visgraph] improve and fix bugs on vis-network relationship graph +* [bugfix] posting of birth- and deathdate through api fixed. ## Test releases @@ -24,6 +30,7 @@ and this project adheres to * [activity] layout for issues / actions * [activity][bugfix] in edit mode, the form will now load the social action list + ### Test release 2021-11-29 * [person] suggest entities (person | thirdparty) when creating/editing the accompanying course (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/119) @@ -51,6 +58,9 @@ and this project adheres to * [activity] for a new activity: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties * [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties + +## Test releases + ### Test release 2021-11-22 * [activity] delete admin_user_show in twig template because this route is not defined and should be defined diff --git a/src/Bundle/ChillActivityBundle/DependencyInjection/ChillActivityExtension.php b/src/Bundle/ChillActivityBundle/DependencyInjection/ChillActivityExtension.php index e1c657947..39c7eab36 100644 --- a/src/Bundle/ChillActivityBundle/DependencyInjection/ChillActivityExtension.php +++ b/src/Bundle/ChillActivityBundle/DependencyInjection/ChillActivityExtension.php @@ -41,6 +41,7 @@ class ChillActivityExtension extends Extension implements PrependExtensionInterf $loader->load('services/form.yaml'); $loader->load('services/templating.yaml'); $loader->load('services/accompanyingPeriodConsistency.yaml'); + $loader->load('services/doctrine.entitylistener.yaml'); } public function prepend(ContainerBuilder $container) diff --git a/src/Bundle/ChillActivityBundle/Entity/Activity.php b/src/Bundle/ChillActivityBundle/Entity/Activity.php index 183b56225..77f650ce4 100644 --- a/src/Bundle/ChillActivityBundle/Entity/Activity.php +++ b/src/Bundle/ChillActivityBundle/Entity/Activity.php @@ -47,7 +47,8 @@ use Symfony\Component\Serializer\Annotation\SerializedName; * }) * @ActivityValidator\ActivityValidity * - * @UserCircleConsistency( + * TODO see if necessary + * UserCircleConsistency( * "CHILL_ACTIVITY_SEE_DETAILS", * getUserFunction="getUser", * path="scope") diff --git a/src/Bundle/ChillActivityBundle/EntityListener/ActivityEntityListener.php b/src/Bundle/ChillActivityBundle/EntityListener/ActivityEntityListener.php new file mode 100644 index 000000000..ab370e89e --- /dev/null +++ b/src/Bundle/ChillActivityBundle/EntityListener/ActivityEntityListener.php @@ -0,0 +1,77 @@ +em = $em; + $this->workRepository = $workRepository; + } + + public function persistActionToCourse(Activity $activity) + { + if ($activity->getAccompanyingPeriod() instanceof AccompanyingPeriod) { + $period = $activity->getAccompanyingPeriod(); + + $accompanyingCourseWorks = $this->workRepository->findByAccompanyingPeriod($period); + $periodActions = []; + $now = new DateTimeImmutable(); + + foreach ($accompanyingCourseWorks as $key => $work) { + // take only the actions which are still opened + if ($work->getEndDate() === null || $work->getEndDate() > ($activity->getDate() ?? $now)) { + $periodActions[$key] = spl_object_hash($work->getSocialAction()); + } + } + + $associatedPersons = $activity->getPersonsAssociated(); + $associatedThirdparties = $activity->getThirdParties(); + + foreach ($activity->getSocialActions() as $action) { + if (in_array(spl_object_hash($action), $periodActions, true)) { + continue; + } + $newAction = new AccompanyingPeriodWork(); + $newAction->setSocialAction($action); + $period->addWork($newAction); + + $date = DateTimeImmutable::createFromMutable($activity->getDate()); + $newAction->setStartDate($date); + + foreach ($associatedPersons as $person) { + $newAction->addPerson($person); + } + + foreach ($associatedThirdparties as $thirdparty) { + $newAction->setHandlingThierparty($thirdparty); + } + $this->em->persist($newAction); + $this->em->flush(); + } + } + } +} diff --git a/src/Bundle/ChillActivityBundle/Form/ActivityType.php b/src/Bundle/ChillActivityBundle/Form/ActivityType.php index 0955a26c2..eec801be5 100644 --- a/src/Bundle/ChillActivityBundle/Form/ActivityType.php +++ b/src/Bundle/ChillActivityBundle/Form/ActivityType.php @@ -143,7 +143,7 @@ class ActivityType extends AbstractType return array_map( fn (string $id): ?SocialIssue => $this->om->getRepository(SocialIssue::class)->findOneBy(['id' => (int) $id]), - explode(',', $socialIssuesAsString) + explode(',', (string) $socialIssuesAsString) ); } )); @@ -169,7 +169,7 @@ class ActivityType extends AbstractType return array_map( fn (string $id): ?SocialAction => $this->om->getRepository(SocialAction::class)->findOneBy(['id' => (int) $id]), - explode(',', $socialActionsAsString) + explode(',', (string) $socialActionsAsString) ); } )); @@ -266,7 +266,7 @@ class ActivityType extends AbstractType return array_map( fn (string $id): ?Person => $this->om->getRepository(Person::class)->findOneBy(['id' => (int) $id]), - explode(',', $personsAsString) + explode(',', (string) $personsAsString) ); } )); @@ -292,7 +292,7 @@ class ActivityType extends AbstractType return array_map( fn (string $id): ?ThirdParty => $this->om->getRepository(ThirdParty::class)->findOneBy(['id' => (int) $id]), - explode(',', $thirdpartyAsString) + explode(',', (string) $thirdpartyAsString) ); } )); @@ -329,7 +329,7 @@ class ActivityType extends AbstractType return array_map( fn (string $id): ?User => $this->om->getRepository(User::class)->findOneBy(['id' => (int) $id]), - explode(',', $usersAsString) + explode(',', (string) $usersAsString) ); } )); @@ -344,7 +344,7 @@ class ActivityType extends AbstractType return ''; } - return $location->getId(); + return (string) $location->getId(); }, function (?string $id): ?Location { return $this->om->getRepository(Location::class)->findOneBy(['id' => (int) $id]); diff --git a/src/Bundle/ChillActivityBundle/config/services/doctrine.entitylistener.yaml b/src/Bundle/ChillActivityBundle/config/services/doctrine.entitylistener.yaml new file mode 100644 index 000000000..3c439153b --- /dev/null +++ b/src/Bundle/ChillActivityBundle/config/services/doctrine.entitylistener.yaml @@ -0,0 +1,15 @@ +services: + Chill\ActivityBundle\EntityListener\ActivityEntityListener: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\ActivityBundle\Entity\Activity' + method: 'persistActionToCourse' + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\ActivityBundle\Entity\Activity' + method: 'persistActionToCourse' diff --git a/src/Bundle/ChillMainBundle/Controller/AddressReferenceAPIController.php b/src/Bundle/ChillMainBundle/Controller/AddressReferenceAPIController.php index 1230cd0f6..498c77c68 100644 --- a/src/Bundle/ChillMainBundle/Controller/AddressReferenceAPIController.php +++ b/src/Bundle/ChillMainBundle/Controller/AddressReferenceAPIController.php @@ -12,14 +12,66 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Entity\PostalCode; +use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\AddressReferenceRepository; +use Chill\MainBundle\Serializer\Model\Collection; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use function trim; -/** - * Class AddressReferenceAPIController. - */ -class AddressReferenceAPIController extends ApiController +final class AddressReferenceAPIController extends ApiController { + private AddressReferenceRepository $addressReferenceRepository; + + private PaginatorFactory $paginatorFactory; + + public function __construct(AddressReferenceRepository $addressReferenceRepository, PaginatorFactory $paginatorFactory) + { + $this->addressReferenceRepository = $addressReferenceRepository; + $this->paginatorFactory = $paginatorFactory; + } + + /** + * @Route("/api/1.0/main/address-reference/by-postal-code/{id}/search.json") + */ + public function search(PostalCode $postalCode, Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if (!$request->query->has('q')) { + throw new BadRequestHttpException('You must supply a "q" parameter'); + } + + $pattern = $request->query->get('q'); + + if ('' === trim($pattern)) { + throw new BadRequestHttpException('the search pattern is empty'); + } + + $nb = $this->addressReferenceRepository->countByPostalCodePattern($postalCode, $pattern); + $paginator = $this->paginatorFactory->create($nb); + $addresses = $this->addressReferenceRepository->findByPostalCodePattern( + $postalCode, + $pattern, + false, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); + + return $this->json( + new Collection($addresses, $paginator), + Response::HTTP_OK, + [], + [AbstractNormalizer::GROUPS => ['read']] + ); + } + protected function customizeQuery(string $action, Request $request, $qb): void { if ($request->query->has('postal_code')) { diff --git a/src/Bundle/ChillMainBundle/Controller/PostalCodeAPIController.php b/src/Bundle/ChillMainBundle/Controller/PostalCodeAPIController.php index fb926733b..fa1a29296 100644 --- a/src/Bundle/ChillMainBundle/Controller/PostalCodeAPIController.php +++ b/src/Bundle/ChillMainBundle/Controller/PostalCodeAPIController.php @@ -12,13 +12,80 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Pagination\PaginatorFactory; +use Chill\MainBundle\Repository\CountryRepository; +use Chill\MainBundle\Repository\PostalCodeRepository; +use Chill\MainBundle\Serializer\Model\Collection; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -/** - * Class PostalCodeAPIController. - */ -class PostalCodeAPIController extends ApiController +final class PostalCodeAPIController extends ApiController { + private CountryRepository $countryRepository; + + private PaginatorFactory $paginatorFactory; + + private PostalCodeRepository $postalCodeRepository; + + public function __construct( + CountryRepository $countryRepository, + PostalCodeRepository $postalCodeRepository, + PaginatorFactory $paginatorFactory + ) { + $this->countryRepository = $countryRepository; + $this->postalCodeRepository = $postalCodeRepository; + $this->paginatorFactory = $paginatorFactory; + } + + /** + * @Route("/api/1.0/main/postal-code/search.json") + */ + public function search(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if (!$request->query->has('q')) { + throw new BadRequestHttpException('You must supply a "q" parameter'); + } + + $pattern = $request->query->get('q'); + + if ('' === trim($pattern)) { + throw new BadRequestHttpException('the search pattern is empty'); + } + + if ($request->query->has('country')) { + $country = $this->countryRepository->find($request->query->getInt('country')); + + if (null === $country) { + throw new NotFoundHttpException('country not found'); + } + } else { + $country = null; + } + + $nb = $this->postalCodeRepository->countByPattern($pattern, $country); + $paginator = $this->paginatorFactory->create($nb); + $codes = $this->postalCodeRepository->findByPattern( + $pattern, + $country, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); + + return $this->json( + new Collection($codes, $paginator), + Response::HTTP_OK, + [], + [AbstractNormalizer::GROUPS => ['read']] + ); + } + protected function customizeQuery(string $action, Request $request, $qb): void { if ($request->query->has('country')) { diff --git a/src/Bundle/ChillMainBundle/Entity/AddressReference.php b/src/Bundle/ChillMainBundle/Entity/AddressReference.php index e793167f6..99efd391d 100644 --- a/src/Bundle/ChillMainBundle/Entity/AddressReference.php +++ b/src/Bundle/ChillMainBundle/Entity/AddressReference.php @@ -22,6 +22,15 @@ use Symfony\Component\Serializer\Annotation\Groups; */ class AddressReference { + /** + * This is an internal column which is populated by database. + * + * This column will ease the search operations + * + * @ORM\Column(type="text", options={"default": ""}) + */ + private string $addressCanonical = ''; + /** * @ORM\Id * @ORM\GeneratedValue diff --git a/src/Bundle/ChillMainBundle/Entity/PostalCode.php b/src/Bundle/ChillMainBundle/Entity/PostalCode.php index 866ad04db..484a9e322 100644 --- a/src/Bundle/ChillMainBundle/Entity/PostalCode.php +++ b/src/Bundle/ChillMainBundle/Entity/PostalCode.php @@ -29,6 +29,15 @@ use Symfony\Component\Serializer\Annotation\Groups; */ class PostalCode { + /** + * This is an internal column which is populated by database. + * + * This column will ease the search operations + * + * @ORM\Column(type="text", options={"default": ""}) + */ + private string $canonical = ''; + /** * @var Point * diff --git a/src/Bundle/ChillMainBundle/Pagination/Paginator.php b/src/Bundle/ChillMainBundle/Pagination/Paginator.php index d6da55618..54fa36084 100644 --- a/src/Bundle/ChillMainBundle/Pagination/Paginator.php +++ b/src/Bundle/ChillMainBundle/Pagination/Paginator.php @@ -109,6 +109,10 @@ class Paginator implements PaginatorInterface return 1; } + if (0 === $this->totalItems) { + return 1; + } + $nb = floor($this->totalItems / $this->itemPerPage); if ($this->totalItems % $this->itemPerPage > 0) { @@ -211,6 +215,10 @@ class Paginator implements PaginatorInterface public function hasPage($number) { + if (0 === $this->totalItems) { + return 1 === $number; + } + return 0 < $number && $this->countPages() >= $number; } diff --git a/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php b/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php index 4344964af..6be52ec85 100644 --- a/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php @@ -12,17 +12,29 @@ declare(strict_types=1); namespace Chill\MainBundle\Repository; use Chill\MainBundle\Entity\AddressReference; +use Chill\MainBundle\Entity\PostalCode; +use Chill\MainBundle\Search\SearchApiQuery; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\Persistence\ObjectRepository; +use RuntimeException; +use function explode; +use function implode; +use function strtr; +use function trim; final class AddressReferenceRepository implements ObjectRepository { + private EntityManagerInterface $entityManager; + private EntityRepository $repository; public function __construct(EntityManagerInterface $entityManager) { $this->repository = $entityManager->getRepository(AddressReference::class); + $this->entityManager = $entityManager; } public function countAll(): int @@ -33,6 +45,18 @@ final class AddressReferenceRepository implements ObjectRepository return $qb->getQuery()->getSingleScalarResult(); } + public function countByPostalCodePattern(PostalCode $postalCode, string $pattern): int + { + $query = $this->buildQueryByPostalCodePattern($postalCode, $pattern); + $sql = $query->buildQuery(true); + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('c', 'c'); + + $nq = $this->entityManager->createNativeQuery($sql, $rsm)->setParameters($query->buildParameters(true)); + + return (int) $nq->getSingleResult()['c']; + } + public function find($id, $lockMode = null, $lockVersion = null): ?AddressReference { return $this->repository->find($id, $lockMode, $lockVersion); @@ -57,6 +81,33 @@ final class AddressReferenceRepository implements ObjectRepository return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } + /** + * @return AddressReference[]|array + */ + public function findByPostalCodePattern(PostalCode $postalCode, string $pattern, bool $simplify = false, int $start = 0, int $limit = 50): array + { + $query = $this->buildQueryByPostalCodePattern($postalCode, $pattern); + + if (!$simplify) { + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(AddressReference::class, 'cma'); + $query->addSelectClause($rsm->generateSelectClause()); + } else { + throw new RuntimeException('not implemented'); + } + + $sql = strtr( + $query->buildQuery() . 'ORDER BY pertinence DESC, lpad(streetnumber, 10, \'0\') ASC OFFSET ? LIMIT ? ', + // little hack for adding sql method to point + ['cma.point AS point' => 'ST_AsGeojson(cma.point) AS point'] + ); + $parameters = [...$query->buildParameters(), $start, $limit]; + + return $this->entityManager->createNativeQuery($sql, $rsm) + ->setParameters($parameters) + ->getResult(); + } + public function findOneBy(array $criteria, ?array $orderBy = null): ?AddressReference { return $this->repository->findOneBy($criteria, $orderBy); @@ -66,4 +117,44 @@ final class AddressReferenceRepository implements ObjectRepository { return AddressReference::class; } + + private function buildQueryByPostalCodePattern(PostalCode $postalCode, string $pattern): SearchApiQuery + { + $pattern = trim($pattern); + + if ('' === $pattern) { + throw new RuntimeException('the search pattern must not be empty'); + } + $query = new SearchApiQuery(); + + $query + ->setFromClause('chill_main_address_reference cma') + ->andWhereClause('postcode_id = ?', [$postalCode->getId()]); + + $pertinenceClause = ['STRICT_WORD_SIMILARITY(addresscanonical, UNACCENT(?))']; + $pertinenceArgs = [$pattern]; + $orWhere = ['addresscanonical %>> UNACCENT(?)']; + $orWhereArgs = [$pattern]; + + foreach (explode(' ', $pattern) as $part) { + $part = trim($part); + + if ('' === $part) { + continue; + } + + $orWhere[] = "addresscanonical LIKE '%' || UNACCENT(LOWER(?)) || '%'"; + $orWhereArgs[] = $part; + $pertinenceClause[] = + "(EXISTS (SELECT 1 FROM unnest(string_to_array(addresscanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int"; + $pertinenceClause[] = + '(addresscanonical LIKE UNACCENT(LOWER(?)))::int'; + array_push($pertinenceArgs, $part, $part); + } + $query + ->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs) + ->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs); + + return $query; + } } diff --git a/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php b/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php index 697c8f90d..02e63771b 100644 --- a/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php @@ -11,18 +11,39 @@ declare(strict_types=1); namespace Chill\MainBundle\Repository; +use Chill\MainBundle\Entity\Country; use Chill\MainBundle\Entity\PostalCode; +use Chill\MainBundle\Search\SearchApiQuery; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\Persistence\ObjectRepository; +use RuntimeException; final class PostalCodeRepository implements ObjectRepository { + private EntityManagerInterface $entityManager; + private EntityRepository $repository; public function __construct(EntityManagerInterface $entityManager) { $this->repository = $entityManager->getRepository(PostalCode::class); + $this->entityManager = $entityManager; + } + + public function countByPattern(string $pattern, ?Country $country): int + { + $query = $this->buildQueryByPattern($pattern, $country); + $sql = $query->buildQuery(true); + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('c', 'c'); + + $nq = $this->entityManager->createNativeQuery($sql, $rsm) + ->setParameters($query->buildParameters(true)); + + return (int) $nq->getSingleResult()['c']; } public function find($id, $lockMode = null, $lockVersion = null): ?PostalCode @@ -49,6 +70,26 @@ final class PostalCodeRepository implements ObjectRepository return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } + public function findByPattern(string $pattern, ?Country $country, ?int $start = 0, ?int $limit = 50): array + { + $query = $this->buildQueryByPattern($pattern, $country); + + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(PostalCode::class, 'cmpc'); + $query->addSelectClause($rsm->generateSelectClause()); + + $sql = strtr( + $query->buildQuery() . 'ORDER BY pertinence DESC, canonical ASC OFFSET ? LIMIT ? ', + // little hack for adding sql method to point + ['cmpc.center AS center' => 'ST_AsGeojson(cmpc.center) AS center'] + ); + $parameters = [...$query->buildParameters(), $start, $limit]; + + return $this->entityManager->createNativeQuery($sql, $rsm) + ->setParameters($parameters) + ->getResult(); + } + public function findOneBy(array $criteria, ?array $orderBy = null): ?PostalCode { return $this->repository->findOneBy($criteria, $orderBy); @@ -58,4 +99,48 @@ final class PostalCodeRepository implements ObjectRepository { return PostalCode::class; } + + private function buildQueryByPattern(string $pattern, ?Country $country): SearchApiQuery + { + $pattern = trim($pattern); + + if ('' === $pattern) { + throw new RuntimeException('the search pattern must not be empty'); + } + $query = new SearchApiQuery(); + + $query + ->setFromClause('chill_main_postal_code cmpc') + ->andWhereClause('cmpc.origin = 0'); + + if (null !== $country) { + $query->andWhereClause('cmpc.country_id = ?', [$country->getId()]); + } + + $pertinenceClause = ['STRICT_WORD_SIMILARITY(canonical, UNACCENT(?))']; + $pertinenceArgs = [$pattern]; + $orWhere = ['canonical %>> UNACCENT(?)']; + $orWhereArgs = [$pattern]; + + foreach (explode(' ', $pattern) as $part) { + $part = trim($part); + + if ('' === $part) { + continue; + } + + $orWhere[] = "canonical LIKE '%' || UNACCENT(LOWER(?)) || '%'"; + $orWhereArgs[] = $part; + $pertinenceClause[] = + "(EXISTS (SELECT 1 FROM unnest(string_to_array(canonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int"; + $pertinenceClause[] = + '(canonical LIKE UNACCENT(LOWER(?)))::int'; + array_push($pertinenceArgs, $part, $part); + } + $query + ->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs) + ->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs); + + return $query; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js index 62065b3ad..1dbc85dee 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js @@ -16,7 +16,8 @@ const fetchCountries = () => { /** * Endpoint chill_api_single_postal_code__index -* method GET, get Country Object +* method GET, get Cities Object +* @params {object} a country object * @returns {Promise} a promise containing all Postal Code objects filtered with country */ const fetchCities = (country) => { @@ -29,6 +30,40 @@ const fetchCities = (country) => { }); }; + +/** +* Endpoint chill_main_postalcodeapi_search +* method GET, get Cities Object +* @params {string} search a search string +* @params {object} country a country object +* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string +*/ +const searchCities = (search, country) => { + const url = `/api/1.0/main/postal-code/search.json?q=${search}&country=${country.id}`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + +/** +* Endpoint chill_main_addressreferenceapi_search +* method GET, get AddressReference Object +* @params {string} search a search string +* @params {object} postalCode a postalCode object +* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string +*/ +const searchReferenceAddresses = (search, postalCode) => { + const url = `/api/1.0/main/address-reference/by-postal-code/${postalCode.id}/search.json?q=${search}`; + return fetch(url) + .then(response => { + if (response.ok) { return response.json(); } + throw Error('Error with request resource response'); + }); +}; + + /** * Endpoint chill_api_single_address_reference__index * method GET, get AddressReference Object @@ -170,5 +205,7 @@ export { postAddress, patchAddress, postPostalCode, - getAddress + getAddress, + searchCities, + searchReferenceAddresses }; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue index 87ecff7fb..ed1d14633 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue @@ -556,8 +556,8 @@ export default { this.entity.selected.address.distribution = this.context.edit ? this.entity.address.distribution: null; this.entity.selected.address.extra = this.context.edit ? this.entity.address.extra: null; - this.entity.selected.writeNew.address = this.context.edit; - this.entity.selected.writeNew.postcode = this.context.edit; + this.entity.selected.writeNew.address = this.context.edit && this.entity.address.addressReference === null && this.entity.address.street.length > 0 + this.entity.selected.writeNew.postcode = false // NB: this used to be this.context.edit, but think it was erroneous; console.log('!! just set writeNew.postcode to', this.entity.selected.writeNew.postcode); }, @@ -569,7 +569,6 @@ export default { applyChanges() { console.log('apply changes'); - let newAddress = { 'isNoAddress': this.entity.selected.isNoAddress, 'street': this.entity.selected.isNoAddress ? '' : this.entity.selected.address.street, @@ -633,7 +632,6 @@ export default { if (!this.context.edit) { this.addNewAddress(newAddress) .then(payload => this.addressChangedCallback(payload)); - } else { this.updateAddress({ addressId: this.context.addressId, @@ -697,8 +695,7 @@ export default { * Async PATCH transactions, * then update existing address with backend datas when promise is resolved */ - updateAddress(payload) - { + updateAddress(payload) { this.flag.loading = true; // TODO change the condition because it writes new postal code in edit mode now: !writeNewPostalCode diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue index 2409dca53..c333961db 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue @@ -18,6 +18,7 @@ :taggable="true" :multiple="false" @tag="addAddress" + :loading="isLoading" :options="addresses"> @@ -48,14 +49,17 @@ diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js index c2ee09960..8047aea11 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js @@ -24,6 +24,7 @@ const visMessages = { refresh: "Rafraîchir", screenshot: "Prendre une photo", choose_relation: "Choisissez le lien de parenté", + relationship_household: "Filiation du ménage", }, edit: 'Éditer', del: 'Supprimer', diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/index.js index ca76f283b..5e8989e49 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/index.js @@ -16,7 +16,12 @@ persons.forEach(person => { }) const app = createApp({ - template: `` + template: ``, + data() { + return { + household_id: JSON.parse(container.dataset.householdId) + } + } }) .use(store) .use(i18n) diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/store.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/store.js index 119a7b29c..10269c6c8 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/store.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/store.js @@ -112,7 +112,7 @@ const store = createStore({ } }) //console.log('array', array.map(item => item.person.id)) - console.log('get persons group', group.map(f => f.id)) + //console.log('get persons group', group.map(f => f.id)) return group }, @@ -120,13 +120,17 @@ const store = createStore({ }, mutations: { addPerson(state, [person, options]) { + let age = getAge(person) + age = (age === '')? '' : ' - ' + age + 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.label = `*${person.text}*\n_${getGender(person.gender)}${age}_${debug}` person.folded = false // folded is used for missing persons if (options.folded) { @@ -161,7 +165,7 @@ const store = createStore({ state.links.push(link) }, updateLink(state, link) { - console.log('updateLink', link) + //console.log('updateLink', link) let link_ = { from: `person_${link.fromPerson.id}`, to: `person_${link.toPerson.id}`, @@ -264,7 +268,7 @@ const store = createStore({ fetchInfoForPerson({ dispatch }, person) { // TODO enfants hors ménages // example: household 61 - // console.log(person.text, 'household', person.current_household_id) + //console.log(person.text, 'household', person.current_household_id) if (null !== person.current_household_id) { dispatch('fetchHouseholdForPerson', person) } @@ -305,15 +309,16 @@ const store = createStore({ */ addLinkFromPersonsToHousehold({ commit, getters, dispatch }, household) { let members = getters.getMembersByHousehold(household.id) - console.log('add link for', members.length, 'members') + //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}`, + to: `${household.id}`, + id: `${household.id}-person_${m.person.id}`, arrows: 'from', color: 'pink', font: { color: '#D04A60' }, + dashes: (getHouseholdWidth(m) === 1)? [0,4] : false, //edge style: [dash, gap, dash, gap] label: getHouseholdLabel(m), width: getHouseholdWidth(m), }) @@ -362,7 +367,7 @@ const store = createStore({ */ addLinkFromPersonsToCourse({ commit, getters, dispatch }, course) { const participations = getters.getParticipationsByCourse(course.id) - console.log('add link for', participations.length, 'participations') + //console.log('add link for', participations.length, 'participations') participations.forEach(p => { //console.log(p.person.id) commit('addLink', { @@ -445,7 +450,7 @@ const store = createStore({ * @param array */ addMissingPerson({ commit, getters, dispatch }, [person, parent]) { - console.log('! add missing Person', person.id) + //console.log('! add missing Person', person.id) commit('markPersonLoaded', person.id) commit('addPerson', [person, { folded: true }]) if (getters.isExcludedNode(parent.id)) { @@ -467,7 +472,7 @@ const store = createStore({ getters.getPersonsGroup(participations) .forEach(person => { if (person.folded === true) { - console.log('-=. unfold and expand person', person.id) + //console.log('-=. unfold and expand person', person.id) commit('unfoldPerson', person) dispatch('fetchInfoForPerson', person) } @@ -485,7 +490,7 @@ const store = createStore({ getters.getPersonsGroup(members) .forEach(person => { if (person.folded === true) { - console.log('-=. unfold and expand person', person.id) + //console.log('-=. unfold and expand person', person.id) commit('unfoldPerson', person) dispatch('fetchInfoForPerson', person) } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/vis-network.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/vis-network.js index e95bc0d0b..3e00db883 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/vis-network.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/vis-network.js @@ -13,13 +13,12 @@ window.options = { locale: 'fr', locales: visMessages, /* + */ configure: { enabled: true, - filter: 'nodes,edges', - //container: undefined, + filter: 'physics', showButton: true }, - */ physics: { enabled: true, barnesHut: { @@ -37,8 +36,8 @@ window.options = { centralGravity: 0.01, springLength: 100, springConstant: 0.08, - damping: 0.4, - avoidOverlap: 0 + damping: 0.75, + avoidOverlap: 0.00 }, repulsion: { centralGravity: 0.2, @@ -159,17 +158,21 @@ const getGender = (gender) => { } /** - * TODO Repeat getAge() in PersonRenderBox.vue - * @param birthdate + * TODO only one abstract function (-> getAge() is repeated in PersonRenderBox.vue) + * @param person * @returns {string|null} */ -const getAge = (birthdate) => { - if (null === birthdate) { - return null +const getAge = (person) => { + if (person.birthdate) { + let birthdate = new Date(person.birthdate.datetime) + if (person.deathdate) { + let deathdate = new Date(person.deathdate.datetime) + return (deathdate.getFullYear() - birthdate.getFullYear()) + visMessages.fr.visgraph.years + } + let now = new Date() + return (now.getFullYear() - birthdate.getFullYear()) + visMessages.fr.visgraph.years } - const birthday = new Date(birthdate.datetime) - const now = new Date() - return (now.getFullYear() - birthday.getFullYear()) + ' '+ visMessages.fr.visgraph.years + return '' } /** diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue index ffb9ef3d4..ab7074ffc 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue @@ -192,6 +192,7 @@ export default { return `/fr/person/${this.person.id}/general`; }, getAge: function() { + // TODO only one abstract function if(this.person.birthdate && !this.person.deathdate){ const birthday = new Date(this.person.birthdate.datetime) const now = new Date() diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Household/relationship.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Household/relationship.html.twig index 56fcce85c..acaeebb96 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Household/relationship.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Household/relationship.html.twig @@ -17,7 +17,8 @@
+ data-persons="{{ persons|e('html_attr') }}" + data-household-id="{{ household.id|e('html_attr') }}">
{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php index c0ba0201a..7049b7c00 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php @@ -18,6 +18,7 @@ use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Repository\PersonRepository; use DateTime; +use DateTimeImmutable; use LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; @@ -114,18 +115,14 @@ class PersonJsonNormalizer implements case 'birthdate': $object = $this->denormalizer->denormalize($data[$item], DateTime::class, $format, $context); - if ($object instanceof DateTime) { - $person->setBirthdate($object); - } + $person->setBirthdate($object); break; case 'deathdate': - $object = $this->denormalizer->denormalize($data[$item], DateTime::class, $format, $context); + $object = $this->denormalizer->denormalize($data[$item], DateTimeImmutable::class, $format, $context); - if ($object instanceof DateTime) { - $person->setDeathdate($object); - } + $person->setDeathdate($object); break; diff --git a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml index c67884a19..3c49cc264 100644 --- a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml @@ -353,6 +353,19 @@ paths: application/json: schema: $ref: "#/components/schemas/Person" + examples: + Update a person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" responses: 401: description: "Unauthorized" @@ -375,6 +388,19 @@ paths: application/json: schema: $ref: "#/components/schemas/Person" + examples: + Create a new person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" responses: 200: description: "OK"