Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/Controller/BulkInfoProviderImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')]
Expand Down Expand Up @@ -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
{
Expand Down
117 changes: 117 additions & 0 deletions src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* 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 <https://www.gnu.org/licenses/>.
*/

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();
}
}
10 changes: 10 additions & 0 deletions src/Services/Parts/PartsTableActionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions templates/components/datatables.macro.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
</optgroup>
<optgroup label="{% trans %}part_list.action.action.info_provider{% endtrans %}">
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_import" data-url="{{ path('bulk_info_provider_step1')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_import{% endtrans %}</option>
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_refresh" data-url="{{ path('bulk_info_provider_refresh')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_refresh{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.images{% endtrans %}">
<option {% if not is_granted('@tools.component_image_generator') %}disabled{% endif %} value="generate_images" data-turbo="false">{% trans %}part_list.action.generate_images{% endtrans %}</option>
Expand Down
3 changes: 2 additions & 1 deletion templates/info_providers/bulk_import/step2.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@
{% for error in part_result.errors %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle"></i>
{{ error }}
{# Errors can either be a translation key or a raw message from a provider #}
{{ error|trans }}
</div>
{% endfor %}
{% endif %}
Expand Down
15 changes: 15 additions & 0 deletions templates/parts/info/_tools.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
{# Update part from info provider button #}
{% if is_granted('edit', part) and is_granted('@info_providers.create_parts') %}
<br>
{# 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 %}
<a class="btn btn-info mt-2" href="{{ path('info_providers_update_part', {
'id': part.iD,
'providerKey': part.providerReference.providerKey,
'providerId': part.providerReference.providerId,
'no_cache': 1,
}) }}" title="{{ part.providerReference.lastUpdated
? ('part.refresh_from_info_provider.last_updated'|trans({'%datetime%': part.providerReference.lastUpdated|format_datetime}))
: ('part.info_provider_reference.updated_never'|trans) }}">
<i class="fas fa-rotate"></i>
{% trans with {'%provider%': part_provider.providerInfo.name} %}part.refresh_from_info_provider.btn{% endtrans %}
</a>
{% endif %}
<a class="btn btn-info mt-2" href="{{ path('info_providers_update_part_search', {'target': part.iD}) }}">
<i class="fas fa-cloud-arrow-down"></i>
{% trans %}part.update_part_from_info_provider.btn{% endtrans %}
Expand Down
123 changes: 123 additions & 0 deletions tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* 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 <https://www.gnu.org/licenses/>.
*/
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);
}
}
Loading
Loading