diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index bcb196e67..e13a725e3 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -31,6 +31,7 @@ use App\Form\InfoProviderSystem\GlobalFieldMappingType; use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\BulkInfoProviderService; +use App\Services\InfoProviderSystem\BulkRefreshResultsBuilder; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; @@ -50,6 +51,7 @@ class BulkInfoProviderImportController extends AbstractController { public function __construct( private readonly BulkInfoProviderService $bulkService, + private readonly BulkRefreshResultsBuilder $refreshResultsBuilder, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, #[Autowire(param: 'partdb.bulk_import.batch_size')] @@ -263,6 +265,76 @@ public function step1(Request $request): Response ]); } + /** + * Refreshes a set of parts from the info provider each of them was created with. + * + * This is the bulk version of the refresh button on a part: no search step is needed, as every part already + * knows where it came from, so the job is created with its results filled in and the user lands directly in the + * review step, where the parts can be applied one by one or all at once. + */ + #[Route('/refresh', name: 'bulk_info_provider_refresh')] + public function refresh(Request $request): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $ids = $request->query->get('ids'); + if (!$ids) { + $this->addFlash('error', 'No parts selected for bulk refresh'); + return $this->redirectToRoute('homepage'); + } + + $parts = $this->entityManager->getRepository(Part::class)->getElementsFromIDArray(explode(',', $ids)); + + if ($parts === []) { + $this->addFlash('error', 'No valid parts found for bulk refresh'); + return $this->redirectToRoute('homepage'); + } + + if (count($parts) > $this->bulkImportMaxParts) { + $this->addFlash('error', sprintf( + 'Too many parts selected (%d). Maximum allowed is %d parts per operation.', + count($parts), + $this->bulkImportMaxParts + )); + return $this->redirectToRoute('homepage'); + } + + $user = $this->getUser(); + if (!$user instanceof User) { + throw new \RuntimeException('User must be authenticated and of type User'); + } + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + + foreach ($parts as $part) { + $job->addJobPart(new BulkInfoProviderImportJobPart($job, $part)); + } + + $this->entityManager->persist($job); + $this->entityManager->flush(); + + try { + $job->setSearchResults($this->refreshResultsBuilder->build($parts)); + $job->markAsInProgress(); + $this->entityManager->flush(); + } catch (\Exception $e) { + $this->logger->error('Critical error during bulk refresh', [ + 'job_id' => $job->getId(), + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + $this->entityManager->remove($job); + $this->entityManager->flush(); + + $this->addFlash('error', 'Refresh failed due to an error: ' . $e->getMessage()); + return $this->redirectToRoute('homepage'); + } + + return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]); + } + #[Route('/manage', name: 'bulk_info_provider_manage')] public function manageBulkJobs(): Response { diff --git a/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php b/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php new file mode 100644 index 000000000..f1b0f9e84 --- /dev/null +++ b/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php @@ -0,0 +1,117 @@ +. + */ + +namespace App\Services\InfoProviderSystem; + +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; + +/** + * Builds the results of a bulk refresh, in the same shape a bulk search produces them, so that a refresh can reuse + * the whole review step of the bulk import (see BulkInfoProviderImportController). + * + * Unlike a search, nothing has to be looked for here: a part which was created by an info provider already knows + * which provider and which provider ID it came from, so its single "result" is exactly that provider entry. + * No provider is contacted while building the results - the data itself is only fetched when the user actually + * applies a part in the review step. + * + * @see \App\Tests\Services\InfoProviderSystem\BulkRefreshResultsBuilderTest + */ +final readonly class BulkRefreshResultsBuilder +{ + public function __construct(private ProviderRegistry $providerRegistry) + { + } + + /** + * @param Part[] $parts The parts which should be refreshed + * @throws \InvalidArgumentException If no parts were given + */ + public function build(array $parts): BulkSearchResponseDTO + { + if ($parts === []) { + throw new \InvalidArgumentException('No valid parts found for bulk refresh'); + } + + $part_results = []; + + foreach ($parts as $part) { + $reference = $part->getProviderReference(); + $provider_key = $reference->getProviderKey(); + $provider_id = $reference->getProviderId(); + + //Parts which can not be refreshed are kept in the job with an error, so they stay visible to the user + //instead of silently disappearing from their selection + if (!$reference->isProviderCreated() || $provider_key === null || $provider_id === null) { + $part_results[] = new BulkSearchPartResultsDTO(part: $part, errors: [ + 'info_providers.bulk_refresh.error.no_provider_reference', + ]); + continue; + } + + if (!$this->isProviderUsable($provider_key)) { + $part_results[] = new BulkSearchPartResultsDTO(part: $part, errors: [ + 'info_providers.bulk_refresh.error.provider_unavailable', + ]); + continue; + } + + $part_results[] = new BulkSearchPartResultsDTO(part: $part, searchResults: [ + new BulkSearchPartResultDTO( + searchResult: new SearchResultDTO( + provider_key: $provider_key, + provider_id: $provider_id, + name: $part->getName(), + description: $part->getDescription(), + manufacturer: $part->getManufacturer()?->getName(), + mpn: $part->getManufacturerProductNumber(), + provider_url: $reference->getProviderUrl(), + ), + //The part is not searched for, it is looked up by the provider ID it was created with + sourceField: 'provider_reference', + sourceKeyword: $provider_id, + ), + ]); + } + + return new BulkSearchResponseDTO($part_results); + } + + /** + * Checks whether the provider with the given key exists in this installation and is currently usable. + */ + private function isProviderUsable(string $provider_key): bool + { + try { + $provider = $this->providerRegistry->getProviderByKey($provider_key); + } catch (\InvalidArgumentException) { + //The provider the part was created with is not part of this installation (anymore) + return false; + } + + return $provider->isActive(); + } +} diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 7837ef281..bdec44258 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -127,6 +127,16 @@ public function handleAction(string $action, array $selected_parts, ?int $target ); } + if ($action === 'bulk_info_provider_refresh') { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + return new RedirectResponse( + $this->urlGenerator->generate('bulk_info_provider_refresh', [ + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + if ($action === 'batch_edit_eda') { $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); return new RedirectResponse( diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 42a30f519..17f980019 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -77,6 +77,7 @@ + diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index e68202e04..43d9fdd61 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -188,7 +188,8 @@ {% for error in part_result.errors %} {% endfor %} {% endif %} diff --git a/templates/parts/info/_tools.html.twig b/templates/parts/info/_tools.html.twig index 455d51b7d..40219803a 100644 --- a/templates/parts/info/_tools.html.twig +++ b/templates/parts/info/_tools.html.twig @@ -33,6 +33,21 @@ {# Update part from info provider button #} {% if is_granted('edit', part) and is_granted('@info_providers.create_parts') %}
+ {# If the part was created by a provider, it can be refreshed directly, without searching for it again #} + {% set part_provider = part.providerReference.providerCreated ? info_provider(part.providerReference.providerKey) : null %} + {% if part_provider and part_provider.active %} + + + {% trans with {'%provider%': part_provider.providerInfo.name} %}part.refresh_from_info_provider.btn{% endtrans %} + + {% endif %} {% trans %}part.update_part_from_info_provider.btn{% endtrans %} diff --git a/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php b/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php new file mode 100644 index 000000000..626ea4df9 --- /dev/null +++ b/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php @@ -0,0 +1,123 @@ +. + */ +namespace App\Tests\Services\InfoProviderSystem; + +use App\Entity\Parts\InfoProviderReference; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\BulkRefreshResultsBuilder; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use PHPUnit\Framework\TestCase; + +final class BulkRefreshResultsBuilderTest extends TestCase +{ + private function builder(bool $provider_active = true): BulkRefreshResultsBuilder + { + $provider = $this->createMock(InfoProviderInterface::class); + $provider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test', name: 'Test provider')); + $provider->method('isActive')->willReturn($provider_active); + + return new BulkRefreshResultsBuilder(new ProviderRegistry([$provider])); + } + + private function partWithReference(?InfoProviderReference $reference): Part + { + $part = new Part(); + $part->setName('Test part'); + $part->setDescription('A part'); + + if ($reference !== null) { + $part->setProviderReference($reference); + } + + return $part; + } + + public function testEmptyPartListIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->builder()->build([]); + } + + public function testPartIsRefreshedFromItsOwnProviderReference(): void + { + $part = $this->partWithReference( + InfoProviderReference::providerReference('test', '1234', 'https://example.com/part/1234') + ); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertCount(1, $results); + $this->assertSame($part, $results[0]->part); + $this->assertFalse($results[0]->hasErrors()); + $this->assertCount(1, $results[0]->searchResults); + + //The single result must point at exactly the provider entry the part was created from + $search_result = $results[0]->searchResults[0]->searchResult; + $this->assertSame('test', $search_result->provider_key); + $this->assertSame('1234', $search_result->provider_id); + $this->assertSame('https://example.com/part/1234', $search_result->provider_url); + } + + public function testPartWithoutProviderReferenceGetsAnError(): void + { + $part = $this->partWithReference(null); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.no_provider_reference'], $results[0]->errors); + } + + public function testPartOfAnUnknownProviderGetsAnError(): void + { + $part = $this->partWithReference(InfoProviderReference::providerReference('does_not_exist', '1234')); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.provider_unavailable'], $results[0]->errors); + } + + public function testPartOfADisabledProviderGetsAnError(): void + { + $part = $this->partWithReference(InfoProviderReference::providerReference('test', '1234')); + + $results = $this->builder(provider_active: false)->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.provider_unavailable'], $results[0]->errors); + } + + public function testPartsAreKeptInTheGivenOrder(): void + { + $refreshable = $this->partWithReference(InfoProviderReference::providerReference('test', '1234')); + $not_refreshable = $this->partWithReference(null); + + $results = $this->builder()->build([$not_refreshable, $refreshable])->partResults; + + $this->assertSame($not_refreshable, $results[0]->part); + $this->assertSame($refreshable, $results[1]->part); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 85958a016..094250cfa 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15254,5 +15254,35 @@ Buerklin-API Authentication server: How long a single request may be delayed by the limits above before it fails instead, in seconds. Bulk operations simply wait; this keeps a request started from the user interface from hanging. + + + part.refresh_from_info_provider.btn + Refresh from %provider% + + + + + part.refresh_from_info_provider.last_updated + Last updated from the info provider: %datetime% + + + + + part_list.action.bulk_info_provider_refresh + Refresh from info providers + + + + + info_providers.bulk_refresh.error.no_provider_reference + This part was not created from an info provider, so it can not be refreshed automatically. + + + + + info_providers.bulk_refresh.error.provider_unavailable + The info provider this part was created with is not available anymore. + +