diff --git a/ci/qa/phpstan-baseline.php b/ci/qa/phpstan-baseline.php index 684e3f5f..7ed0bfef 100644 --- a/ci/qa/phpstan-baseline.php +++ b/ci/qa/phpstan-baseline.php @@ -343,6 +343,12 @@ 'count' => 1, 'path' => __DIR__ . '/../../src/Surfnet/StepupRa/RaBundle/Controller/SecondFactorController.php', ]; +$ignoreErrors[] = [ + 'message' => '#^Call to an undefined method Symfony\\\\Component\\\\Form\\\\FormInterface\\\\:\\:getClickedButton\\(\\)\\.$#', + 'identifier' => 'method.notFound', + 'count' => 1, + 'path' => __DIR__ . '/../../src/Surfnet/StepupRa/RaBundle/Controller/RaManagementController.php', +]; $ignoreErrors[] = [ 'message' => '#^Call to an undefined method Symfony\\\\Component\\\\Security\\\\Core\\\\User\\\\UserProviderInterface\\:\\:findById\\(\\)\\.$#', 'identifier' => 'method.notFound', @@ -715,12 +721,6 @@ 'count' => 1, 'path' => __DIR__ . '/../../src/Surfnet/StepupRa/RaBundle/Service/RaCandidateService.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Left side of && is always true\\.$#', - 'identifier' => 'booleanAnd.leftAlwaysTrue', - 'count' => 2, - 'path' => __DIR__ . '/../../src/Surfnet/StepupRa/RaBundle/Service/RaListingService.php', -]; $ignoreErrors[] = [ 'message' => '#^Parameter \\#1 \\$raInstitution of method Surfnet\\\\StepupMiddlewareClient\\\\Identity\\\\Dto\\\\RaListingSearchQuery\\:\\:setRaInstitution\\(\\) expects string, string\\|null given\\.$#', 'identifier' => 'argument.type', diff --git a/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php b/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php new file mode 100644 index 00000000..fc473ecc --- /dev/null +++ b/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php @@ -0,0 +1,85 @@ +actorId = $command->actorId; + $exportCommand->name = $command->name; + $exportCommand->email = $command->email; + $exportCommand->institution = $command->institution; + $exportCommand->roleAtInstitution = $command->roleAtInstitution; + + return $exportCommand; + } + + public function getFileName(): string + { + $date = new DateTime(); + $date = $date->format('Y-m-d'); + + $role = $this->roleAtInstitution instanceof RoleAtInstitution && $this->roleAtInstitution->hasRole() + ? $this->roleAtInstitution->getRole() + : null; + + return match ($role) { + 'ra' => "ra_export_{$date}", + 'raa' => "raa_export_{$date}", + default => "ra-raa-export_{$date}", + }; + } +} diff --git a/src/Surfnet/StepupRa/RaBundle/Controller/RaManagementController.php b/src/Surfnet/StepupRa/RaBundle/Controller/RaManagementController.php index e9547258..33572fec 100755 --- a/src/Surfnet/StepupRa/RaBundle/Controller/RaManagementController.php +++ b/src/Surfnet/StepupRa/RaBundle/Controller/RaManagementController.php @@ -24,6 +24,7 @@ use Surfnet\StepupMiddlewareClientBundle\Identity\Dto\RaListing; use Surfnet\StepupRa\RaBundle\Command\AccreditCandidateCommand; use Surfnet\StepupRa\RaBundle\Command\AmendRegistrationAuthorityInformationCommand; +use Surfnet\StepupRa\RaBundle\Command\ExportRaListingCommand; use Surfnet\StepupRa\RaBundle\Command\RetractRegistrationAuthorityCommand; use Surfnet\StepupRa\RaBundle\Command\SearchRaCandidatesCommand; use Surfnet\StepupRa\RaBundle\Command\SearchRaListingCommand; @@ -89,6 +90,11 @@ public function manage(Request $request): Response $form = $this->createForm(SearchRaListingType::class, $command, ['method' => 'get']); $form->handleRequest($request); + if ($form->isSubmitted() && $form->getClickedButton()?->getName() === 'export') { + $this->logger->notice('Forwarding to export RA(A) listing action'); + return $this->forward('\Surfnet\StepupRa\RaBundle\Controller\RaManagementController::export', ['command' => $command]); + } + $raList = $this->raListingService->search($command); $pagination = $this->paginator->paginate( @@ -117,6 +123,16 @@ public function manage(Request $request): Response ); } + #[IsGranted('ROLE_RAA')] + public function export(SearchRaListingCommand $command): Response + { + $this->logger->notice('Starting export of searched RA(A) listing'); + + $exportCommand = ExportRaListingCommand::fromSearchCommand($command); + + return $this->raListingService->export($exportCommand); + } + #[Route( path: '/management/search-ra-candidate', name: 'ra_management_ra_candidate_search', diff --git a/src/Surfnet/StepupRa/RaBundle/Form/Type/RoleAtInstitutionType.php b/src/Surfnet/StepupRa/RaBundle/Form/Type/RoleAtInstitutionType.php index db7c1cfa..f6f6c1d9 100755 --- a/src/Surfnet/StepupRa/RaBundle/Form/Type/RoleAtInstitutionType.php +++ b/src/Surfnet/StepupRa/RaBundle/Form/Type/RoleAtInstitutionType.php @@ -52,6 +52,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => RaRoleChoiceList::create(), 'choice_value' => fn($choice) => $choice, 'required' => $isRequired, + 'placeholder' => $isRequired ? null : 'ra.form.role_at_institution.placeholder.role', ])->add('institution', ChoiceType::class, [ 'label' => 'ra.form.role_at_institution.label.institution', 'choices' => $selectRaaOptions, diff --git a/src/Surfnet/StepupRa/RaBundle/Form/Type/SearchRaListingType.php b/src/Surfnet/StepupRa/RaBundle/Form/Type/SearchRaListingType.php index 3ab96c48..0cee0a8f 100755 --- a/src/Surfnet/StepupRa/RaBundle/Form/Type/SearchRaListingType.php +++ b/src/Surfnet/StepupRa/RaBundle/Form/Type/SearchRaListingType.php @@ -50,10 +50,25 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => $data->raInstitutionFilterOptions, 'label' => 'ra.form.ra_search_ra_listing.label.role', 'required' => false, - ])->add('search', SubmitType::class, [ + ]); + + $buttonGroup = $builder->create( + 'button-group', + ButtonGroupType::class, + [ + 'mapped' => false, + ], + ) + ->add('search', SubmitType::class, [ 'label' => 'ra.form.ra_search_ra_listing.button.search', 'attr' => ['class' => 'btn btn-primary search-button'], + ]) + ->add('export', SubmitType::class, [ + 'label' => 'ra.form.ra_search_ra_listing.button.export', + 'attr' => ['class' => 'btn btn-secondary'], ]); + + $builder->add($buttonGroup); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Surfnet/StepupRa/RaBundle/Resources/config/services.yml b/src/Surfnet/StepupRa/RaBundle/Resources/config/services.yml index 3ae3ff9c..68045670 100755 --- a/src/Surfnet/StepupRa/RaBundle/Resources/config/services.yml +++ b/src/Surfnet/StepupRa/RaBundle/Resources/config/services.yml @@ -215,11 +215,16 @@ services: Surfnet\StepupRa\RaBundle\Service\RaLocationService: alias: ra.service.ra_location + ra.service.ra_listing_exporter: + class: Surfnet\StepupRa\RaBundle\Service\RaListingExport + arguments: + - "@logger" + ra.service.ra_listing: class: Surfnet\StepupRa\RaBundle\Service\RaListingService arguments: - "@surfnet_stepup_middleware_client.identity.service.ra_listing" - - "@logger" + - "@ra.service.ra_listing_exporter" Surfnet\StepupRa\RaBundle\Service\RaListingService: alias: ra.service.ra_listing diff --git a/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php b/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php new file mode 100644 index 00000000..f9b9f20d --- /dev/null +++ b/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php @@ -0,0 +1,86 @@ +logger->notice(sprintf('Exporting %d rows to "%s"', count($raListings), $fileName)); + + $columnNames = $this->getColumnNames(); + + return new StreamedResponse( + function () use ($raListings, $columnNames) { + $handle = fopen('php://output', 'r+'); + if ($handle === false) { + throw new RuntimeException('Unable to open php://output for writing the RA(A) listing export'); + } + fputcsv($handle, $columnNames); + foreach ($raListings as $raListing) { + fputcsv($handle, [ + $raListing->commonName, + $raListing->email, + $raListing->institution, + $raListing->role, + $raListing->raInstitution, + $raListing->location, + $raListing->contactInformation, + ]); + } + fflush($handle); + fclose($handle); + }, + Response::HTTP_OK, + [ + 'Content-Type' => 'application/csv', + 'Content-Disposition' => sprintf('attachment; filename="%s.csv"', $fileName), + ], + ); + } + + /** + * @return string[] + */ + private function getColumnNames(): array + { + return [ + 'Common Name', + 'Email', + 'Institution', + 'Role', + 'RA Institution', + 'Location', + 'Contact Information', + ]; + } +} diff --git a/src/Surfnet/StepupRa/RaBundle/Service/RaListingService.php b/src/Surfnet/StepupRa/RaBundle/Service/RaListingService.php index a540e286..5577b8a6 100755 --- a/src/Surfnet/StepupRa/RaBundle/Service/RaListingService.php +++ b/src/Surfnet/StepupRa/RaBundle/Service/RaListingService.php @@ -22,12 +22,16 @@ use Surfnet\StepupMiddlewareClientBundle\Identity\Dto\RaListing; use Surfnet\StepupMiddlewareClientBundle\Identity\Dto\RaListingCollection; use Surfnet\StepupMiddlewareClientBundle\Identity\Service\RaListingService as ApiRaListingService; +use Surfnet\StepupRa\RaBundle\Command\ExportRaListingCommand; use Surfnet\StepupRa\RaBundle\Command\SearchRaListingCommand; +use Surfnet\StepupRa\RaBundle\Value\RoleAtInstitution; +use Symfony\Component\HttpFoundation\StreamedResponse; final readonly class RaListingService { public function __construct( private ApiRaListingService $apiRaListingService, + private RaListingExport $raListingExport, ) { } @@ -37,41 +41,98 @@ public function __construct( */ public function search(SearchRaListingCommand $command): RaListingCollection { - $query = new RaListingSearchQuery($command->actorId, $command->pageNumber); + $query = $this->buildQuery( + $command->actorId, + $command->pageNumber, + $command->name, + $command->email, + $command->institution, + $command->roleAtInstitution, + $command->orderBy, + $command->orderDirection, + ); - if ($command->name) { - $query->setName($command->name); - } + return $this->apiRaListingService->search($query); + } + + public function get(string $identityId, string $institution, string $actorId): ?RaListing + { + return $this->apiRaListingService->get($identityId, $institution, $actorId); + } + + /** + * Pages through all RA listing results matching the given filters (bypassing UI pagination) + * and streams them as a CSV export. + */ + public function export(ExportRaListingCommand $command): StreamedResponse + { + $raListings = []; + $pageNumber = 1; + + do { + $query = $this->buildQuery( + $command->actorId, + $pageNumber, + $command->name, + $command->email, + $command->institution, + $command->roleAtInstitution, + ); + + $collection = $this->apiRaListingService->search($query); + $raListings = array_merge($raListings, $collection->getElements()); - if ($command->email) { - $query->setEmail($command->email); + $lastPage = (int) ceil($collection->getTotalItems() / max($collection->getItemsPerPage(), 1)); + $pageNumber++; + } while ($pageNumber <= $lastPage); + + return $this->raListingExport->export($raListings, $command->getFileName()); + } + + /** + * @SuppressWarnings("PHPMD.CyclomaticComplexity") -- The command to query mapping exceeds the + * @SuppressWarnings("PHPMD.NPathComplexity") CyclomaticComplexity and NPathComplexity threshold. + */ + private function buildQuery( + string $actorId, + int $pageNumber, + ?string $name, + ?string $email, + ?string $institution, + ?RoleAtInstitution $roleAtInstitution, + ?string $orderBy = null, + ?string $orderDirection = null, + ): RaListingSearchQuery { + $query = new RaListingSearchQuery($actorId, $pageNumber); + + if ($name) { + $query->setName($name); } - if ($command->institution) { - $query->setInstitution($command->institution); + if ($email) { + $query->setEmail($email); } - if ($command->roleAtInstitution && $command->roleAtInstitution->hasRole()) { - $query->setRole($command->roleAtInstitution->getRole()); + if ($institution) { + $query->setInstitution($institution); } - if ($command->roleAtInstitution && $command->roleAtInstitution->hasInstitution()) { - $query->setRaInstitution($command->roleAtInstitution->getInstitution()); + if ($roleAtInstitution && $roleAtInstitution->hasRole()) { + $query->setRole($roleAtInstitution->getRole()); } - if ($command->orderBy) { - $query->setOrderBy($command->orderBy); + if ($roleAtInstitution && $roleAtInstitution->hasInstitution()) { + $query->setRaInstitution($roleAtInstitution->getInstitution()); } - if ($command->orderDirection) { - $query->setOrderDirection($command->orderDirection); + if ($orderBy) { + $query->setOrderBy($orderBy); } - return $this->apiRaListingService->search($query); - } + if ($orderDirection) { + $query->setOrderDirection($orderDirection); + } - public function get(string $identityId, string $institution, string $actorId): ?RaListing - { - return $this->apiRaListingService->get($identityId, $institution, $actorId); + return $query; } } diff --git a/translations/messages+intl-icu.en_GB.xliff b/translations/messages+intl-icu.en_GB.xliff index ec1e129b..51911100 100644 --- a/translations/messages+intl-icu.en_GB.xliff +++ b/translations/messages+intl-icu.en_GB.xliff @@ -241,6 +241,10 @@ ra.form.ra_search_ra_candidates.label.name Name + + ra.form.ra_search_ra_listing.button.export + Export + ra.form.ra_search_ra_listing.button.search Search @@ -373,6 +377,10 @@ ra.form.role_at_institution.label.institution @ + + ra.form.role_at_institution.placeholder.role + RA/RAA + ra.form.select_institution.button.switch Switch diff --git a/translations/messages+intl-icu.nl_NL.xliff b/translations/messages+intl-icu.nl_NL.xliff index 2fb99919..4ac60577 100644 --- a/translations/messages+intl-icu.nl_NL.xliff +++ b/translations/messages+intl-icu.nl_NL.xliff @@ -241,6 +241,10 @@ ra.form.ra_search_ra_candidates.label.name Naam + + ra.form.ra_search_ra_listing.button.export + Exporteren + ra.form.ra_search_ra_listing.button.search Zoeken @@ -373,6 +377,10 @@ ra.form.role_at_institution.label.institution ra.form.role_at_institution.label.institution + + ra.form.role_at_institution.placeholder.role + RA/RAA + ra.form.select_institution.button.switch Laden