From c32ba2bee40d034543f65dcee26d94db6957f4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 27 Aug 2021 15:24:51 +0200 Subject: [PATCH] import duplication from 2.0 branch --- composer.json | 3 +- .../Phonenumber/PhonenumberHelper.php | 2 - .../Controller/PersonDuplicateController.php | 256 ++++++++++++++++++ .../Entity/PersonNotDuplicate.php | 107 ++++++++ .../Form/PersonConfimDuplicateType.php | 31 +++ .../Form/PersonFindManuallyDuplicateType.php | 40 +++ .../Menu/PersonMenuBuilder.php | 25 +- .../PersonNotDuplicateRepository.php | 33 +++ .../doctrine/PersonNotDuplicate.orm.yml | 20 ++ .../Resources/config/routing.yml | 22 +- .../Resources/config/services/controller.yml | 4 + .../Resources/config/services/menu.yml | 6 +- .../Resources/config/services/repository.yml | 8 +- .../Resources/config/services/search.yml | 5 +- .../migrations/Version20210128152747.php | 39 +++ .../Resources/translations/messages.fr.yml | 31 +++ .../views/PersonDuplicate/_sidepane.html.twig | 38 +++ .../views/PersonDuplicate/confirm.html.twig | 93 +++++++ .../PersonDuplicate/find_manually.html.twig | 31 +++ .../views/PersonDuplicate/view.html.twig | 159 +++++++++++ .../Search/SimilarPersonMatcher.php | 106 +++++--- .../Security/Authorization/PersonVoter.php | 7 +- 22 files changed, 1012 insertions(+), 54 deletions(-) create mode 100644 src/Bundle/ChillPersonBundle/Controller/PersonDuplicateController.php create mode 100644 src/Bundle/ChillPersonBundle/Entity/PersonNotDuplicate.php create mode 100644 src/Bundle/ChillPersonBundle/Form/PersonConfimDuplicateType.php create mode 100644 src/Bundle/ChillPersonBundle/Form/PersonFindManuallyDuplicateType.php create mode 100644 src/Bundle/ChillPersonBundle/Repository/PersonNotDuplicateRepository.php create mode 100644 src/Bundle/ChillPersonBundle/Resources/config/doctrine/PersonNotDuplicate.orm.yml create mode 100644 src/Bundle/ChillPersonBundle/Resources/migrations/Version20210128152747.php create mode 100644 src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/_sidepane.html.twig create mode 100644 src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/confirm.html.twig create mode 100644 src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/find_manually.html.twig create mode 100644 src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/view.html.twig diff --git a/composer.json b/composer.json index 9902125db..5fb3200d4 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,8 @@ "sensio/distribution-bundle": "^5.0", "knplabs/knp-menu-bundle": "^2.2", "league/csv": "^9.0", - "champs-libres/async-uploader-bundle": "~1.0" + "champs-libres/async-uploader-bundle": "~1.0", + "laminas/laminas-zendframework-bridge": "~1.3" }, "require-dev": { "symfony/dom-crawler": "~3.4", diff --git a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php index 8b3df2894..8782ebbe4 100644 --- a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php +++ b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php @@ -169,7 +169,6 @@ class PhonenumberHelper } catch (ClientException $e) { $this->logger->error("[phonenumber helper] Could not format number " . "due to client error", [ - "message" => $e->getResponseBodySummary($e->getResponse()), "status_code" => $e->getResponse()->getStatusCode(), "phonenumber" => $phonenumber ]); @@ -178,7 +177,6 @@ class PhonenumberHelper } catch (ServerException $e) { $this->logger->error("[phonenumber helper] Could not format number " . "due to server error", [ - "message" => $e->getResponseBodySummary($e->getResponse()), "status_code" => $e->getResponse()->getStatusCode(), "phonenumber" => $phonenumber ]); diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonDuplicateController.php b/src/Bundle/ChillPersonBundle/Controller/PersonDuplicateController.php new file mode 100644 index 000000000..0d46055ac --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonDuplicateController.php @@ -0,0 +1,256 @@ +similarPersonMatcher = $similarPersonMatcher; + $this->translator = $translator; + $this->personRepository = $personRepository; + $this->personMove = $personMove; + $this->eventDispatcher = $eventDispatcher; + } + + public function viewAction($person_id) + { + $person = $this->_getPerson($person_id); + if ($person === null) { + throw $this->createNotFoundException("Person with id $person_id not" + . " found on this server"); + } + + $this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person, + "You are not allowed to see this person."); + + $duplicatePersons = $this->similarPersonMatcher-> + matchPerson($person, 0.5, SimilarPersonMatcher::SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL); + + $notDuplicatePersons = $this->getDoctrine()->getRepository(PersonNotDuplicate::class) + ->findByNotDuplicatePerson($person); + + return $this->render('ChillPersonBundle:PersonDuplicate:view.html.twig', [ + 'person' => $person, + 'duplicatePersons' => $duplicatePersons, + 'notDuplicatePersons' => $notDuplicatePersons, + ]); + } + + public function confirmAction($person1_id, $person2_id, Request $request) + { + [$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id); + + $this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1, + "You are not allowed to see this person."); + + $form = $this->createForm(PersonConfimDuplicateType::class); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $event = new PrivacyEvent($person1, array( + 'element_class' => Person::class, + 'action' => 'move' + )); + $event->addPerson($person2); + $this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event); + + $sqls = $this->personMove->getSQL($person2, $person1); + + $connection = $this->getDoctrine()->getConnection(); + + $connection->beginTransaction(); + foreach($sqls as $sql) { + $connection->executeQuery($sql); + } + $connection->commit(); + + $this->addFlash('success', $this->translator->trans('The de-duplicate operation success')); + + return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]); + } + + return $this->render('ChillPersonBundle:PersonDuplicate:confirm.html.twig', [ + 'person' => $person1, + 'person2' => $person2, + 'form' => $form->createView(), + ]); + } + + public function notDuplicateAction($person1_id, $person2_id) + { + [$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id); + + $this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1, + "You are not allowed to see this person."); + + $personNotDuplicate = $this->getDoctrine()->getRepository(PersonNotDuplicate::class) + ->findOneBy(['person1' => $person1, 'person2' => $person2]); + + if (!$personNotDuplicate instanceof PersonNotDuplicate) { + $personNotDuplicate = new PersonNotDuplicate(); + $personNotDuplicate->setPerson1($person1); + $personNotDuplicate->setPerson2($person2); + $personNotDuplicate->setUser($this->getUser()); + + $this->getDoctrine()->getManager()->persist($personNotDuplicate); + $this->getDoctrine()->getManager()->flush(); + } + + return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]); + } + + public function removeNotDuplicateAction($person1_id, $person2_id) + { + [$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id); + + $this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1, + "You are not allowed to see this person."); + + $personNotDuplicate = $this->getDoctrine()->getRepository(PersonNotDuplicate::class) + ->findOneBy(['person1' => $person1, 'person2' => $person2]); + + if ($personNotDuplicate instanceof PersonNotDuplicate) { + $this->getDoctrine()->getManager()->remove($personNotDuplicate); + $this->getDoctrine()->getManager()->flush(); + } + + return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]); + } + + public function findManuallyDuplicateAction($person_id, Request $request) + { + $person = $this->_getPerson($person_id); + if ($person === null) { + throw $this->createNotFoundException("Person with id $person_id not" + . " found on this server"); + } + + $this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person, + "You are not allowed to see this person."); + + $form = $this->createForm(PersonFindManuallyDuplicateType::class); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $person2 = $form->get('person')->getData(); + + if ($person2 === null) { + throw $this->createNotFoundException("Person with id $person2->getId() not" + . " found on this server"); + } + + if ($person === $person2) { + $this->addFlash('error', $this->translator->trans('You cannot add duplicate with same person')); + } elseif ($person->getCenter() !== $person2->getCenter()) { + $this->addFlash('error', $this->translator->trans('You cannot duplicate two persons in two different centers')); + } else { + + $direction = $form->get('direction')->getData(); + + if ($direction === 'starting') { + $params = [ + 'person1_id' => $person->getId(), + 'person2_id' => $person2->getId(), + ]; + } else { + $params = [ + 'person1_id' => $person2->getId(), + 'person2_id' => $person->getId(), + ]; + } + + return $this->redirectToRoute('chill_person_duplicate_confirm', $params); + } + } + + return $this->render('ChillPersonBundle:PersonDuplicate:find_manually.html.twig', [ + 'person' => $person, + 'form' => $form->createView(), + ]); + } + + + /** + * easy getting a person by his id + */ + private function _getPerson($id): ?Person + { + return $this->personRepository->find($id); + } + + private function _getPersonsByPriority($person1_id, $person2_id) + { + if ($person1_id === $person2_id) { + throw new InvalidArgumentException('Can not merge same person'); + } + + if ($person1_id > $person2_id) { + $person1 = $this->_getPerson($person2_id); + $person2 = $this->_getPerson($person1_id); + } else { + $person1 = $this->_getPerson($person1_id); + $person2 = $this->_getPerson($person2_id); + } + + if ($person1 === null) { + throw $this->createNotFoundException("Person with id $person1_id not" + . " found on this server"); + } + + if ($person2 === null) { + throw $this->createNotFoundException("Person with id $person2_id not" + . " found on this server"); + } + + return [$person1, $person2]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Entity/PersonNotDuplicate.php b/src/Bundle/ChillPersonBundle/Entity/PersonNotDuplicate.php new file mode 100644 index 000000000..9f116c501 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/PersonNotDuplicate.php @@ -0,0 +1,107 @@ +date = new \DateTime(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id) + { + $this->id = $id; + } + + public function getPerson1() + { + return $this->person1; + } + + public function setPerson1(Person $person1) + { + $this->person1 = $person1; + } + + public function getPerson2() + { + return $this->person2; + } + + public function setPerson2(Person $person2) + { + $this->person2 = $person2; + } + + public function getDate() + { + return $this->date; + } + + public function setDate(\DateTime $date) + { + $this->date = $date; + } + + public function getUser() + { + return $this->user; + } + + public function setUser(User $user) + { + $this->user = $user; + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/PersonConfimDuplicateType.php b/src/Bundle/ChillPersonBundle/Form/PersonConfimDuplicateType.php new file mode 100644 index 000000000..fd7fa3e50 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Form/PersonConfimDuplicateType.php @@ -0,0 +1,31 @@ +add('confirm', CheckboxType::class, [ + 'label' => 'I confirm the merger of these 2 people', + 'mapped' => false, + ]); + } + + /** + * @return string + */ + public function getBlockPrefix() + { + return 'chill_personbundle_person_confirm_duplicate'; + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/PersonFindManuallyDuplicateType.php b/src/Bundle/ChillPersonBundle/Form/PersonFindManuallyDuplicateType.php new file mode 100644 index 000000000..7d9494249 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Form/PersonFindManuallyDuplicateType.php @@ -0,0 +1,40 @@ +add('person', PickPersonType::class, [ + 'label' => 'Find duplicate', + 'mapped' => false, + ]) + ->add('direction', HiddenType::class, [ + 'data' => 'starting', + ]) + ; + } + + + /** + * @return string + */ + public function getBlockPrefix() + { + return 'chill_personbundle_person_find_manually_duplicate'; + } +} diff --git a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php index 5cefe6b96..852d19ff4 100644 --- a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php +++ b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php @@ -18,7 +18,12 @@ namespace Chill\PersonBundle\Menu; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\PersonBundle\Security\Authorization\PersonVoter; use Knp\Menu\MenuItem; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Translation\TranslatorInterface; /** @@ -43,13 +48,17 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface * @var TranslatorInterface */ protected $translator; - + + protected AuthorizationCheckerInterface $security; + public function __construct( $showAccompanyingPeriod, - TranslatorInterface $translator + TranslatorInterface $translator, + AuthorizationCheckerInterface $security ) { $this->showAccompanyingPeriod = $showAccompanyingPeriod; $this->translator = $translator; + $this->security = $security; } public function buildMenu($menuId, MenuItem $menu, array $parameters) @@ -75,6 +84,18 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface 'order' => 100 ]); } + + if ($this->security->isGranted(PersonVoter::DUPLICATE, $parameters['person'])) { + $menu->addChild($this->translator->trans('Person duplicate'), [ + 'route' => 'chill_person_duplicate_view', + 'routeParameters' => [ + 'person_id' => $parameters['person']->getId() + ] + ]) + ->setExtras([ + 'order' => 99999 + ]); + } } public static function getMenuIds(): array diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonNotDuplicateRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonNotDuplicateRepository.php new file mode 100644 index 000000000..80e6bfa0f --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/PersonNotDuplicateRepository.php @@ -0,0 +1,33 @@ +createQueryBuilder('pnd'); + $qb->select('pnd') + ->where('pnd.person1 = :person OR pnd.person2 = :person') + ; + $qb->setParameter('person', $person); + $result = $qb->getQuery()->getResult(); + + $persons = []; + + foreach ($result as $row) { + if ($row->getPerson1() === $person) { + $persons[] = $row->getPerson2(); + } elseif ($row->getPerson2() === $person) { + $persons[] = $row->getPerson1(); + } + } + + return $persons; + } +} diff --git a/src/Bundle/ChillPersonBundle/Resources/config/doctrine/PersonNotDuplicate.orm.yml b/src/Bundle/ChillPersonBundle/Resources/config/doctrine/PersonNotDuplicate.orm.yml new file mode 100644 index 000000000..2a2cb4931 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/config/doctrine/PersonNotDuplicate.orm.yml @@ -0,0 +1,20 @@ +Chill\PersonBundle\Entity\PersonNotDuplicate: + type: entity + table: chill_person_not_duplicate + repositoryClass: Chill\PersonBundle\Repository\PersonNotDuplicateRepository + id: + id: + type: integer + id: true + generator: + strategy: AUTO + fields: + date: + type: datetime + manyToOne: + person1: + targetEntity: Chill\PersonBundle\Entity\Person + person2: + targetEntity: Chill\PersonBundle\Entity\Person + user: + targetEntity: Chill\MainBundle\Entity\User diff --git a/src/Bundle/ChillPersonBundle/Resources/config/routing.yml b/src/Bundle/ChillPersonBundle/Resources/config/routing.yml index 5c072b257..856c76596 100644 --- a/src/Bundle/ChillPersonBundle/Resources/config/routing.yml +++ b/src/Bundle/ChillPersonBundle/Resources/config/routing.yml @@ -87,4 +87,24 @@ chill_person_timeline: chill_person_admin: path: "/{_locale}/admin/person" - defaults: { _controller: ChillPersonBundle:Admin:index } \ No newline at end of file + defaults: { _controller: ChillPersonBundle:Admin:index } + +chill_person_duplicate_view: + path: /{_locale}/person/{person_id}/duplicate/view + controller: Chill\PersonBundle\Controller\PersonDuplicateController::viewAction + +chill_person_duplicate_confirm: + path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/confirm + controller: Chill\PersonBundle\Controller\PersonDuplicateController::confirmAction + +chill_person_duplicate_not_duplicate: + path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/not-duplicate + controller: Chill\PersonBundle\Controller\PersonDuplicateController::notDuplicateAction + +chill_person_remove_duplicate_not_duplicate: + path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/remove-not-duplicate + controller: Chill\PersonBundle\Controller\PersonDuplicateController::removeNotDuplicateAction + +chill_person_find_manually_duplicate: + path: /{_locale}/person/{person_id}/find-manually + controller: Chill\PersonBundle\Controller\PersonDuplicateController::findManuallyDuplicateAction diff --git a/src/Bundle/ChillPersonBundle/Resources/config/services/controller.yml b/src/Bundle/ChillPersonBundle/Resources/config/services/controller.yml index 10961e4fd..b9fb8df99 100644 --- a/src/Bundle/ChillPersonBundle/Resources/config/services/controller.yml +++ b/src/Bundle/ChillPersonBundle/Resources/config/services/controller.yml @@ -17,3 +17,7 @@ services: $eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface' Chill\PersonBundle\Controller\AdminController: ~ + + Chill\PersonBundle\Controller\PersonDuplicateController: + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillPersonBundle/Resources/config/services/menu.yml b/src/Bundle/ChillPersonBundle/Resources/config/services/menu.yml index 74e00ee67..95cf18e8b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/config/services/menu.yml +++ b/src/Bundle/ChillPersonBundle/Resources/config/services/menu.yml @@ -12,8 +12,10 @@ services: - { name: 'chill.menu_builder' } Chill\PersonBundle\Menu\PersonMenuBuilder: + autowire: true arguments: $showAccompanyingPeriod: '%chill_person.accompanying_period%' - $translator: '@Symfony\Component\Translation\TranslatorInterface' - tags: + # $translator: '@Symfony\Component\Translation\TranslatorInterface' + # $security: '@Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface' + tags: - { name: 'chill.menu_builder' } diff --git a/src/Bundle/ChillPersonBundle/Resources/config/services/repository.yml b/src/Bundle/ChillPersonBundle/Resources/config/services/repository.yml index 435b6f0e5..fd1d1c752 100644 --- a/src/Bundle/ChillPersonBundle/Resources/config/services/repository.yml +++ b/src/Bundle/ChillPersonBundle/Resources/config/services/repository.yml @@ -11,7 +11,13 @@ services: factory: ['@doctrine.orm.entity_manager', getRepository] arguments: - 'Chill\PersonBundle\Entity\Person' - + + Chill\PersonBundle\Repository\PersonNotDuplicateRepository: + class: Chill\PersonBundle\Person\PersonNotDuplicateRepository + factory: [ '@doctrine.orm.entity_manager', getRepository ] + arguments: + - 'Chill\PersonBundle\Entity\PersonNotDuplicate' + Chill\PersonBundle\Repository\ClosingMotiveRepository: class: Chill\PersonBundle\Repository\ClosingMotiveRepository factory: ['@doctrine.orm.entity_manager', getRepository] diff --git a/src/Bundle/ChillPersonBundle/Resources/config/services/search.yml b/src/Bundle/ChillPersonBundle/Resources/config/services/search.yml index 8b4db1373..388c3477d 100644 --- a/src/Bundle/ChillPersonBundle/Resources/config/services/search.yml +++ b/src/Bundle/ChillPersonBundle/Resources/config/services/search.yml @@ -24,7 +24,4 @@ services: - { name: chill.search, alias: 'person_similarity' } Chill\PersonBundle\Search\SimilarPersonMatcher: - arguments: - $em: '@Doctrine\ORM\EntityManagerInterface' - $tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface' - $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' + autowire: true diff --git a/src/Bundle/ChillPersonBundle/Resources/migrations/Version20210128152747.php b/src/Bundle/ChillPersonBundle/Resources/migrations/Version20210128152747.php new file mode 100644 index 000000000..17e3724c6 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/migrations/Version20210128152747.php @@ -0,0 +1,39 @@ +addSql('CREATE SEQUENCE chill_person_not_duplicate_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_person_not_duplicate (id INT NOT NULL, person1_id INT DEFAULT NULL, person2_id INT DEFAULT NULL, user_id INT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_BD211EE23EF5821B ON chill_person_not_duplicate (person1_id)'); + $this->addSql('CREATE INDEX IDX_BD211EE22C402DF5 ON chill_person_not_duplicate (person2_id)'); + $this->addSql('CREATE INDEX IDX_BD211EE2A76ED395 ON chill_person_not_duplicate (user_id)'); + $this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE23EF5821B FOREIGN KEY (person1_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE22C402DF5 FOREIGN KEY (person2_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE2A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP SEQUENCE chill_person_not_duplicate_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_person_not_duplicate'); + } +} \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/Resources/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/Resources/translations/messages.fr.yml index da30718f6..0aa974e1e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/Resources/translations/messages.fr.yml @@ -149,6 +149,37 @@ Add an address: Ajouter une adresse Back to the person details: Retour aux détails de la personne Move to another address: Nouvelle adresse +# dédoublonnage +Old person: Doublon +Old person explain: sera supprimé lors de la fusion +New person: Dossier cible +New person explain: sera conservé lors de la fusion +I confirm the merger of these 2 people : Je confime la fusion de ces 2 dossiers +Person duplicate explained: Chill a détecté des doublons potentiels ! Vous pouvez confirmer, infirmer, ou encore désigner manuellement un autre doublon. +Person flaged as duplicate: Dossiers marqués comme faux-positif +Person flaged as duplicate explained: Les dossiers suivants sont marqués comme faux-positifs. Ce ne sont pas des doublons ! +Associate manually a duplicate person: Désigner manuellement un doublon +Invert: Inverser le sens de la fusion +Find duplicate: Trouver un doublon +Person duplicate: Traiter les doublons +Open in another window: Ouvrir dans une nouvelle fenêtre +Deleted datas: Données supprimées +Keeped datas: Données conservées +Moved links: Relations déplacées +Keeped links: Relations conservées +Merge duplicate persons folders: Fusion de dossiers +Merge: Fusionner +duplicate: Doublon +not-duplicate: Faux-positif +Switch to truefalse: Marquer comme faux-positif +Switch to duplicate: Marquer comme doublon potentiel +No duplicate candidates: Il n'y a pas de doublons détectés, ni de faux-positifs +You cannot add duplicate with same person: Indiquez une autre personne. Il n'est pas possible de fusionner un dossier de personne avec elle-même. +You cannot duplicate two persons in two different centers: Il n'est pas possible de fusionner les dossiers dans deux centres différents +CHILL_PERSON_DUPLICATE: Gestion des doublons +The de-duplicate operation success: L'opération de dé-doublonnage s'est terminée avec succès + + #timeline Timeline: Historique Closing the accompanying period: Fermeture de la période d'accompagnement diff --git a/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/_sidepane.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/_sidepane.html.twig new file mode 100644 index 000000000..c889d2d55 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/_sidepane.html.twig @@ -0,0 +1,38 @@ +{%- macro details(person, options) -%} + + +{% endmacro %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/confirm.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/confirm.html.twig new file mode 100644 index 000000000..d7cc55950 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/confirm.html.twig @@ -0,0 +1,93 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% set activeRouteKey = 'chill_person_duplicate' %} +{% import '@ChillPerson/PersonDuplicate/_sidepane.html.twig' as sidepane %} + +{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~ +' ' ~ person.lastName }}{% endblock %} + +{% block content %} + +
+ +

{{ 'Merge duplicate persons folders'|trans }}

+ +
+

{{ 'Old person'|trans }}: + {{ 'Old person explain'|trans }} +

+
+ +

+ {{ person2 }} +

+ +

{{ 'Deleted datas'|trans ~ ':' }}

+ {{ sidepane.details(person2) }} + +
+
+ +
+

{{ 'New person'|trans }}: + {{ 'New person explain'|trans }} +

+
+ +

+ {{ person }} +

+ +

{{ 'Keeped datas'|trans ~ ':' }}

+ {{ sidepane.details(person) }} + +
+
+ + {{ form_start(form) }} + +
+ +
+
+ {{ form_widget(form.confirm) }} + {{ form_label(form.confirm) }} +
+
+
+ + + + {{ form_end(form) }} + +
+{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/find_manually.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/find_manually.html.twig new file mode 100644 index 000000000..c71c43eb8 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/find_manually.html.twig @@ -0,0 +1,31 @@ +{% extends "@ChillPerson/layout.html.twig" %} + +{% set activeRouteKey = 'chill_person_duplicate' %} + +{% block title %}{{ 'Find duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~ +' ' ~ person.lastName }}{% endblock %} + + +{% block personcontent %} +
+ +

{{ 'Désigner un dossier doublon'|trans }}

+ + {{ form_start(form) }} + {{ form_rest(form) }} + + + + {{ form_end(form) }} + +
+{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/view.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/view.html.twig new file mode 100644 index 000000000..6fbfee716 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/PersonDuplicate/view.html.twig @@ -0,0 +1,159 @@ +{% extends "@ChillPerson/layout.html.twig" %} + +{% set activeRouteKey = 'chill_person_duplicate' %} + +{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~ + ' ' ~ person.lastName }}{% endblock %} + +{% block personcontent %} +
+ +

{{ title|default('Person duplicate')|trans }}

+ + {% if duplicatePersons|length > 0 %} +

{{ title|default('Person duplicate explained')|trans }}

+ + + + + + + + + + + + {% for duplicatePerson in duplicatePersons %} + + + + + + + {% endfor %} +
{% trans %}Name{% endtrans %}{% trans %}Date of birth{% endtrans %}{% trans %}Nationality{% endtrans %} 
+ {% set is_open = duplicatePerson.isOpen() %} + + {{ duplicatePerson|chill_entity_render_box }} + {% apply spaceless %} + {% if chill_person.fields.accompanying_period == 'visible' %} + {% if is_open == false %} + + {% else %} + + {% endif %} + {% endif %} + {% endapply %} + + + {% if duplicatePerson.birthdate is not null %} + {{ duplicatePerson.birthdate|localizeddate('long', 'none') }} + {% else %} + {{ 'Unknown date of birth'|trans }} + {% endif %} + + {% if duplicatePerson.nationality is not null %} + {{ duplicatePerson.nationality.name|localize_translatable_string }} + {% else %} + {{ 'Without nationality'|trans }} + {% endif %} + + +
+ {% endif %} + + {% if notDuplicatePersons|length > 0 %} +

{{ 'Person flaged as duplicate' | trans }}

+

{{ 'Person flaged as duplicate explained' | trans }}

+ + + + + + + + + + + + {% for notDuplicatePerson in notDuplicatePersons %} + + + + + + + {% endfor %} +
{% trans %}Name{% endtrans %}{% trans %}Date of birth{% endtrans %}{% trans %}Nationality{% endtrans %} 
+ {% set is_open = notDuplicatePerson.isOpen() %} + + {{ notDuplicatePerson|chill_entity_render_box }} + {% apply spaceless %} + {% if chill_person.fields.accompanying_period == 'visible' %} + {% if is_open == false %} + + {% else %} + + {% endif %} + {% endif %} + {% endapply %} + + + {% if notDuplicatePerson.birthdate is not null %} + {{ notDuplicatePerson.birthdate|localizeddate('long', 'none') }} + {% else %} + {{ 'Unknown date of birth'|trans }} + {% endif %} + + {% if notDuplicatePerson.nationality is not null %} + {{ notDuplicatePerson.nationality.name|localize_translatable_string }} + {% else %} + {{ 'Without nationality'|trans }} + {% endif %} + + +
+ {% endif %} + + {% if notDuplicatePersons|length == 0 and duplicatePersons|length == 0 %} + {{ 'No duplicate candidates'|trans }} + {% endif %} + + + +
+{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Search/SimilarPersonMatcher.php b/src/Bundle/ChillPersonBundle/Search/SimilarPersonMatcher.php index ab13e607a..67ae94855 100644 --- a/src/Bundle/ChillPersonBundle/Search/SimilarPersonMatcher.php +++ b/src/Bundle/ChillPersonBundle/Search/SimilarPersonMatcher.php @@ -18,78 +18,108 @@ */ namespace Chill\PersonBundle\Search; +use Chill\PersonBundle\Entity\PersonNotDuplicate; +use Chill\PersonBundle\Templating\Entity\PersonRender; use Doctrine\ORM\EntityManagerInterface; use Chill\PersonBundle\Entity\Person; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Chill\PersonBundle\Repository\PersonNotDuplicateRepository; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Role\Role; use Chill\PersonBundle\Security\Authorization\PersonVoter; /** - * + * * * @author Julien Fastré */ class SimilarPersonMatcher { + CONST SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL = 'alphabetical'; + + CONST SIMILAR_SEARCH_ORDER_BY_SIMILARITY = 'similarity'; + /** - * * @var EntityManagerInterface */ protected $em; - + /** - * * @var AuthorizationHelper */ protected $authorizationHelper; - + /** - * - * @var TokenStorageInterface + * @var TokenStorageInterface */ protected $tokenStorage; - + + protected PersonNotDuplicateRepository $personNotDuplicateRepository; + + protected PersonRender $personRender; + public function __construct( - EntityManagerInterface $em, - AuthorizationHelper $authorizationHelper, - TokenStorageInterface $tokenStorage + EntityManagerInterface $em, + AuthorizationHelper $authorizationHelper, + TokenStorageInterface $tokenStorage, + PersonNotDuplicateRepository $personNotDuplicateRepository, + PersonRender $personRender ) { $this->em = $em; $this->authorizationHelper = $authorizationHelper; $this->tokenStorage = $tokenStorage; + $this->personNotDuplicateRepository = $personNotDuplicateRepository; + $this->personRender = $personRender; } - - public function matchPerson(Person $person) - { - $centers = $this->authorizationHelper - ->getReachableCenters( - $this->tokenStorage->getToken()->getUser(), - new Role(PersonVoter::SEE) - ); - - $dql = 'SELECT p from ChillPersonBundle:Person p WHERE' - . ' (' - . ' UNACCENT(LOWER(p.firstName)) LIKE UNACCENT(LOWER(:firstName)) ' - . ' OR UNACCENT(LOWER(p.lastName)) LIKE UNACCENT(LOWER(:lastName)) ' - . ' OR UNACCENT(LOWER(p.firstName)) LIKE UNACCENT(LOWER(:lastName)) ' - . ' OR UNACCENT(LOWER(p.lastName)) LIKE UNACCENT(LOWER(:firstName)) ' - . ' OR SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= 0.15 ' + public function matchPerson( + Person $person, + float $precision = 0.15, + string $orderBy = self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY, + bool $addYearComparison = false + ) { + $centers = $this->authorizationHelper->getReachableCenters( + $this->tokenStorage->getToken()->getUser(), + new Role(PersonVoter::SEE) + ); + $query = $this->em->createQuery(); + + $dql = 'SELECT p from ChillPersonBundle:Person p ' + . ' WHERE (' + . ' SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= :precision ' . ' ) ' . ' AND p.center IN (:centers)' - . ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC ' - ; - - $query = - $this->em - ->createQuery($dql) - ->setParameter('firstName', $person->getFirstName()) - ->setParameter('lastName', $person->getLastName()) - ->setParameter('fullName', $person->getFirstName() . ' ' . $person->getLastName()) + + ; + + if ($person->getId() !== NULL) { + $dql .= ' AND p.id != :personId '; + $notDuplicatePersons = $this->personNotDuplicateRepository->findByNotDuplicatePerson($person); + + $query->setParameter('personId', $person->getId()); + + if (count($notDuplicatePersons)) { + $dql .= ' AND p.id not in (:notDuplicatePersons)'; + $query->setParameter('notDuplicatePersons', $notDuplicatePersons); + } + } + + switch ($orderBy) { + case self::SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL: + $dql .= ' ORDER BY p.fullnameCanonical ASC '; + break; + case self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY: + default : + $dql .= ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC '; + } + + $query = $query + ->setDQL($dql) + ->setParameter('fullName', $this->personRender->renderString($person, [])) ->setParameter('centers', $centers) - ; - + ->setParameter('precision', $precision) + ; + return $query->getResult(); } } diff --git a/src/Bundle/ChillPersonBundle/Security/Authorization/PersonVoter.php b/src/Bundle/ChillPersonBundle/Security/Authorization/PersonVoter.php index 1d3229567..f81a6efa1 100644 --- a/src/Bundle/ChillPersonBundle/Security/Authorization/PersonVoter.php +++ b/src/Bundle/ChillPersonBundle/Security/Authorization/PersonVoter.php @@ -40,6 +40,7 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte const SEE = 'CHILL_PERSON_SEE'; const STATS = 'CHILL_PERSON_STATS'; const LISTS = 'CHILL_PERSON_LISTS'; + const DUPLICATE = 'CHILL_PERSON_DUPLICATE'; /** * @@ -56,11 +57,11 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte { if ($subject instanceof Person) { return \in_array($attribute, [ - self::CREATE, self::UPDATE, self::SEE + self::CREATE, self::UPDATE, self::SEE, self::DUPLICATE ]); } elseif ($subject instanceof Center) { return \in_array($attribute, [ - self::STATS, self::LISTS + self::STATS, self::LISTS, self::DUPLICATE ]); } elseif ($subject === null) { return $attribute === self::CREATE; @@ -87,7 +88,7 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte private function getAttributes() { - return array(self::CREATE, self::UPDATE, self::SEE, self::STATS, self::LISTS); + return array(self::CREATE, self::UPDATE, self::SEE, self::STATS, self::LISTS, self::DUPLICATE); } public function getRoles()