diff --git a/Command/ChillImportUsersCommand.php b/Command/ChillImportUsersCommand.php new file mode 100644 index 000000000..db05cc250 --- /dev/null +++ b/Command/ChillImportUsersCommand.php @@ -0,0 +1,403 @@ +em = $em; + $this->passwordEncoder = $passwordEncoder; + $this->validator = $validator; + $this->logger = $logger; + + + $this->userRepository = $em->getRepository(User::class); + + parent::__construct('chill:main:import-users'); + } + + + + protected function configure() + { + $this + ->setDescription('Import users from csv file') + ->setHelp("Import users from a csv file. Users are added to centers contained in the file. Headers are used to detect columns. Adding to multiple centers can be done by using a `grouping centers` file, which will group multiple centers into a signle alias, used in 'centers' column.") + ->addArgument('csvfile', InputArgument::REQUIRED, 'Path to the csv file. Columns are: `username`, `email`, `center` (can contain alias), `permission group`') + ->addOption('grouping-centers', null, InputOption::VALUE_OPTIONAL, 'Path to a csv file to aggregate multiple centers into a single alias') + ->addOption('dry-run', null, InputOption::VALUE_OPTIONAL, 'Do not commit the changes') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->tempOutput = $output; + $this->tempInput = $input; + + if ($input->hasOption('dry-run')) { + $this->doChanges = false; + } + + if ($input->hasOption('grouping-centers')) { + $this->prepareGroupingCenters(); + } + + $this->loadUsers(); + } + + protected function loadUsers() + { + $reader = Reader::createFromPath($this->tempInput->getArgument('csvfile')); + $reader->setHeaderOffset(0); + + foreach ($reader->getRecords() as $line => $r) { + $this->logger->debug("starting handling new line", [ + 'line' => $line + ]); + + if ($this->doesUserExists($r)) { + $this->tempOutput->writeln(sprintf("User with username '%s' already " + . "exists, skipping")); + + $this->logger->info("One user already exists, skipping creation", [ + 'username_in_file' => $r['username'], + 'email_in_file' => $r['email'], + 'line' => $line + ]); + + continue; + } + + $this->createUser($line, $r); + } + } + + protected function doesUserExists($data) + { + if ($this->userRepository->countByUsernameOrEmail($data['username']) > 0) { + return true; + } + + if ($this->userRepository->countByUsernameOrEmail($data['email']) > 0) { + return true; + } + + return false; + } + + protected function createUser($offset, $data) + { + $user = new User(); + $user + ->setEmail($data['email']) + ->setUsername($data['username']) + ->setEnabled(true) + ->setPassword($this->passwordEncoder->encodePassword($user, + \bin2hex(\random_bytes(32)))) + ; + + $errors = $this->validator->validate($user); + + if ($errors->count() > 0) { + $errorMessages = $this->concatenateViolations($errors); + + $this->tempOutput->writeln(sprintf("%d errors found with user with username \"%s\" at line %d", $errors->count(), $data['username'], $offset)); + $this->tempOutput->writeln($errorMessages); + + throw new \RuntimeException("Found errors while creating an user. " + . "Watch messages in command output"); + } + + $pgs = $this->getPermissionGroup($data['permission group']); + $centers = $this->getCenters($data['center']); + + foreach($pgs as $pg) { + foreach ($centers as $center) { + $user->addGroupCenter($this->createOrGetGroupCenter($center, $pg)); + } + } + + + if ($this->doChanges) { + $this->em->persist($user); + $this->flush(); + } + + $this->logger->notice("Create user", [ + 'username' => $user->getUsername(), + 'id' => $user->getId(), + 'nb_of_groupCenters' => $user->getGroupCenters()->count() + ]); + + } + + protected function getPermissionGroup($alias) + { + if (\array_key_exists($alias, $this->permissionGroups)) { + return $this->permissionGroups[$alias]; + } + + $permissionGroupsByName = []; + + foreach($this->em->getRepository(PermissionsGroup::class) + ->findAll() as $permissionGroup) { + $permissionGroupsByName[$permissionGroup->getName()] = $permissionGroup; + } + + if (count($permissionGroupsByName) === 0) { + throw new \RuntimeException("no permission groups found. Create them " + . "before importing users"); + } + + $question = new ChoiceQuestion("To which permission groups associate with \"$alias\" ?", + \array_keys($permissionGroupsByName)); + $question + ->setMultiselect(true) + ->setAutocompleterValues(\array_keys($permissionGroupsByName)) + ->setNormalizer(function($value) { + if (NULL === $value) { return ''; } + + return \trim($value); + }) + ; + $helper = $this->getHelper('question'); + + $keys = $helper->ask($this->tempInput, $this->tempOutput, $question); + + $this->tempOutput->writeln("You have chosen ".\implode(", ", $keys)); + + if ($helper->ask($this->tempInput, $this->tempOutput, + new ConfirmationQuestion("Are you sure ?", true))) { + + foreach ($keys as $key) { + $this->permissionGroups[$alias][] = $permissionGroupsByName[$key]; + } + + return $this->permissionGroups[$alias]; + } else { + $this->logger->error("Error while responding to a a question"); + + $this->tempOutput("Ok, I accept, but I do not know what to do. Please try again."); + + throw new \RuntimeException("Error while responding to a question"); + } + } + + /** + * + * @param Center $center + * @param \Chill\MainBundle\Command\PermissionGroup $pg + * @return GroupCenter + */ + protected function createOrGetGroupCenter(Center $center, PermissionsGroup $pg): GroupCenter + { + if (\array_key_exists($center->getId(), $this->groupCenters)) { + if (\array_key_exists($pg->getId(), $this->groupCenters[$center->getId()])) { + return $this->groupCenters[$center->getId()][$pg->getId()]; + } + } + + $repository = $this->em->getRepository(GroupCenter::class); + + $groupCenter = $repository->findOneBy(array( + 'center' => $center, + 'permissionsGroup' => $pg + )); + + if ($groupCenter === NULL) { + $groupCenter = new GroupCenter(); + $groupCenter + ->setCenter($center) + ->setPermissionsGroup($pg) + ; + + $this->em->persist($groupCenter); + } + + $this->groupCenters[$center->getId()][$pg->getId()] = $groupCenter; + + return $groupCenter; + } + + protected function prepareGroupingCenters() + { + $reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers')); + $reader->setHeaderOffset(0); + + foreach ($reader->getRecords() as $r) { + $this->centers[$r['alias']] = + \array_merge( + $this->centers[$r['alias']] ?? [], + $this->getCenters($r['center'] + ) + ); + } + } + + /** + * return a list of centers matching the name of alias. + * + * If the name match one center, this center is returned in an array. + * + * If the name match an alias, the centers corresponding to the alias are + * returned in an array. + * + * If the center is not found or alias is not created, a new center is created + * and suggested to user + * + * @param string $name the name of the center or the alias regrouping center + * @return Center[] + */ + protected function getCenters($name) + { + if (\array_key_exists($name, $this->centers)) { + return $this->centers[$name]; + } + + // search for a center with given name + $center = $this->em->getRepository(Center::class) + ->findOneByName($name); + + if ($center instanceof Center) { + $this->centers[$name] = [$center]; + + return $this->centers[$name]; + } + + // suggest and create + $center = (new Center()) + ->setName($name); + + $this->tempOutput->writeln("Center with name \"$name\" not found."); + $qFormatter = $this->getHelper('question'); + $question = new ConfirmationQuestion("Create a center with name \"$name\" ?", true); + + if ($qFormatter->ask($this->tempInput, $this->tempOutput, $question)) { + $this->centers[$name] = [ $center ]; + + $errors = $this->validator->validate($center); + + if ($errors->count() > 0) { + $errorMessages = $this->concatenateViolations($errors); + + $this->tempOutput->writeln(sprintf("%d errors found with center with name \"%s\"", $errors->count(), $name)); + $this->tempOutput->writeln($errorMessages); + + throw new \RuntimeException("Found errors while creating one center. " + . "Watch messages in command output"); + } + + $this->em->persist($center); + + return $this->centers[$name]; + } + + return null; + } + + protected function concatenateViolations(ConstraintViolationListInterface $list) + { + $str = []; + + foreach ($list as $e) { + /* @var $e \Symfony\Component\Validator\ConstraintViolationInterface */ + $str[] = $e->getMessage(); + } + + return \implode(";", $str); + } + +} diff --git a/DependencyInjection/ChillMainExtension.php b/DependencyInjection/ChillMainExtension.php index 5cae303de..be61a3d49 100644 --- a/DependencyInjection/ChillMainExtension.php +++ b/DependencyInjection/ChillMainExtension.php @@ -1,7 +1,7 @@ + * Copyright (C) 2014-2018 Julien Fastré * * 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 @@ -108,6 +108,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $loader->load('services/security.yml'); $loader->load('services/notification.yml'); $loader->load('services/redis.yml'); + $loader->load('services/command.yml'); } public function getConfiguration(array $config, ContainerBuilder $container) diff --git a/Entity/User.php b/Entity/User.php index c814d7a01..b483cb906 100644 --- a/Entity/User.php +++ b/Entity/User.php @@ -229,6 +229,8 @@ class User implements AdvancedUserInterface { public function setEnabled($enabled) { $this->enabled = $enabled; + + return $this; } /** diff --git a/Repository/UserRepository.php b/Repository/UserRepository.php new file mode 100644 index 000000000..ca7053351 --- /dev/null +++ b/Repository/UserRepository.php @@ -0,0 +1,67 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Repository; + +/** + * + * + */ +class UserRepository extends \Doctrine\ORM\EntityRepository +{ + public function countByUsernameOrEmail($pattern) + { + $qb = $this->queryByUsernameOrEmail($pattern); + + $qb->select('COUNT(u)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + public function findByUsernameOrEmail($pattern) + { + $qb = $this->queryByUsernameOrEmail($pattern); + + return $qb->getQuery()->getResult(); + } + + public function findOneByUsernameOrEmail($pattern) + { + $qb = $this->queryByUsernameOrEmail($pattern); + + return $qb->getQuery()->getSingleResult(); + } + + protected function queryByUsernameOrEmail($pattern) + { + $qb = $this->createQueryBuilder('u'); + + $searchByPattern = $qb->expr()->orX(); + + $searchByPattern + ->add($qb->expr()->eq('u.usernameCanonical', 'LOWER(UNACCENT(:pattern))')) + ->add($qb->expr()->eq('u.emailCanonical', 'LOWER(UNACCENT(:pattern))')) + ; + + $qb + ->where($searchByPattern) + ->setParameter('pattern', $pattern) + ; + + return $qb; + } +} diff --git a/Resources/config/doctrine/User.orm.yml b/Resources/config/doctrine/User.orm.yml index 08d8064d3..ad1398d3d 100644 --- a/Resources/config/doctrine/User.orm.yml +++ b/Resources/config/doctrine/User.orm.yml @@ -1,6 +1,7 @@ Chill\MainBundle\Entity\User: type: entity table: users + repositoryClass: Chill\MainBundle\Repository\UserRepository cache: usage: NONSTRICT_READ_WRITE region: acl_cache_region diff --git a/Resources/config/services/command.yml b/Resources/config/services/command.yml new file mode 100644 index 000000000..51065922f --- /dev/null +++ b/Resources/config/services/command.yml @@ -0,0 +1,10 @@ +services: + Chill\MainBundle\Command\ChillImportUsersCommand: + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' + $logger: '@Psr\Log\LoggerInterface' + $passwordEncoder: '@Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface' + $validator: '@Symfony\Component\Validator\Validator\ValidatorInterface' + + tags: + - { name: console.command }