From 2141d25117332c643c918f7a17c540c2036d166e Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 21 Jul 2026 12:11:47 +0200 Subject: [PATCH 1/2] feat(ra): add CSV export to RA Management search results If applied, this commit will let RAA users export the current RA Management search results as a CSV file, and rename the blank Role filter option to a descriptive label. Why is this change needed? Prior to this change, the RA Management page had no export option, unlike the existing Tokens (Second Factors) page. Institution admins had no way to get an offline copy of their RA(A) listing. The Role filter dropdown also had an unlabeled blank option between "RA" and "RAA", which was unclear to users. How does it address the issue? This change adds an Export button next to Search, following the same button-group pattern used on the Tokens page. Since RA listings have no unpaginated export endpoint in the middleware client (unlike Second Factors), RaListingService::export() pages through the existing paginated search results and aggregates them before handing off to the new RaListingExport service, which streams the results as CSV via fputcsv. The exported filename is derived from the active role filter: ra_export_YYYY-MM-DD, raa_export_YYYY-MM-DD, or ra-raa-export_YYYY-MM-DD when no role filter is set. The Role filter's blank option now shows a "RA/RAA" placeholder instead of being empty. The export action keeps its own ROLE_RAA authorization check independent of the search action, so it stays protected even if the search page's access level changes later. Institution-scoping and existing authorization boundaries are unchanged. Links / references: https://github.com/OpenConext/Stepup-RA/issues/498 --- .../Command/ExportRaListingCommand.php | 82 ++++++++++++++ .../Controller/RaManagementController.php | 16 +++ .../Form/Type/RoleAtInstitutionType.php | 1 + .../Form/Type/SearchRaListingType.php | 17 ++- .../RaBundle/Resources/config/services.yml | 7 +- .../RaBundle/Service/RaListingExport.php | 79 ++++++++++++++ .../RaBundle/Service/RaListingService.php | 103 ++++++++++++++---- translations/messages+intl-icu.en_GB.xliff | 8 ++ translations/messages+intl-icu.nl_NL.xliff | 8 ++ 9 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php create mode 100644 src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php diff --git a/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php b/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php new file mode 100644 index 00000000..6a73756c --- /dev/null +++ b/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php @@ -0,0 +1,82 @@ +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..af1cd36f --- /dev/null +++ b/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php @@ -0,0 +1,79 @@ +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+'); + 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), + ], + ); + } + + 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 From 3bb06adebe9d05dc6c222106a881813490944a50 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 21 Jul 2026 12:19:48 +0200 Subject: [PATCH 2/2] fix(ra): satisfy phpstan for RA Management export If applied, this commit will make the CI QA pipeline pass for the RA Management CSV export feature. Why is this change needed? Prior to this change, CI's phpstan step failed on the new export code: an untyped/uninitialized $actorId property, a stale baseline entry for a booleanAnd.leftAlwaysTrue error that the buildQuery() refactor already fixed by making its $roleAtInstitution parameter nullable, unchecked resource|false returns from fopen() in RaListingExport, a missing iterable value type on getColumnNames(), and a new getClickedButton() call on FormInterface that needed the same baseline treatment already used for the equivalent Second Factors export form. How does it address the issue? This change adds a `@var string` docblock to ExportRaListingCommand::$actorId (matching the existing ExportRaSecondFactorsCommand convention), removes the now-stale booleanAnd.leftAlwaysTrue baseline entry for RaListingService, adds a baseline entry for RaManagementController's getClickedButton() call mirroring SecondFactorController's, guards the fopen() result in RaListingExport before using it, and adds a `@return string[]` docblock to getColumnNames(). --- ci/qa/phpstan-baseline.php | 12 ++++++------ .../RaBundle/Command/ExportRaListingCommand.php | 3 +++ .../StepupRa/RaBundle/Service/RaListingExport.php | 7 +++++++ 3 files changed, 16 insertions(+), 6 deletions(-) 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 index 6a73756c..fc473ecc 100644 --- a/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php +++ b/src/Surfnet/StepupRa/RaBundle/Command/ExportRaListingCommand.php @@ -24,6 +24,9 @@ final class ExportRaListingCommand { + /** + * @var string + */ #[Assert\NotBlank(message: 'ra.search_ra_listing.actor_id.blank')] #[Assert\Type('string', message: 'ra.search_ra_listing.actor_id.type')] public $actorId; diff --git a/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php b/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php index af1cd36f..f9b9f20d 100644 --- a/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php +++ b/src/Surfnet/StepupRa/RaBundle/Service/RaListingExport.php @@ -19,6 +19,7 @@ namespace Surfnet\StepupRa\RaBundle\Service; use Psr\Log\LoggerInterface; +use RuntimeException; use Surfnet\StepupMiddlewareClientBundle\Identity\Dto\RaListing; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -41,6 +42,9 @@ public function export(array $raListings, string $fileName): StreamedResponse 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, [ @@ -64,6 +68,9 @@ function () use ($raListings, $columnNames) { ); } + /** + * @return string[] + */ private function getColumnNames(): array { return [