From 9e3cb4d6941ce0930522792f1d1166d2344cda94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 8 Jul 2023 23:49:47 +0200 Subject: [PATCH 01/34] Use enum for manufacturing status --- src/DataTables/Column/EnumColumn.php | 8 ++- src/DataTables/PartsDataTable.php | 21 ++++---- src/Entity/Parts/ManufacturingStatus.php | 53 +++++++++++++++++++ .../Parts/PartTraits/ManufacturerTrait.php | 16 +++--- src/Form/Part/PartBaseType.php | 15 ++---- src/Twig/TwigCoreExtension.php | 1 + templates/helper.twig | 4 ++ 7 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 src/Entity/Parts/ManufacturingStatus.php diff --git a/src/DataTables/Column/EnumColumn.php b/src/DataTables/Column/EnumColumn.php index 04813db9..e41b79e4 100644 --- a/src/DataTables/Column/EnumColumn.php +++ b/src/DataTables/Column/EnumColumn.php @@ -31,10 +31,14 @@ class EnumColumn extends AbstractColumn { /** - * @phpstan-return T + * @phpstan-return T|null */ - public function normalize($value): UnitEnum + public function normalize($value): ?UnitEnum { + if ($value === null) { + return null; + } + if (is_a($value, $this->getEnumClass())) { return $value; } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index d8b34098..848b78a9 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace App\DataTables; +use App\DataTables\Column\EnumColumn; +use App\Entity\Parts\ManufacturingStatus; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Storelocation; use App\DataTables\Adapters\CustomFetchJoinORMAdapter; @@ -227,18 +229,17 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.favorite'), 'visible' => false, ]) - ->add('manufacturing_status', MapColumn::class, [ + ->add('manufacturing_status', EnumColumn::class, [ 'label' => $this->translator->trans('part.table.manufacturingStatus'), 'visible' => false, - 'default' => $this->translator->trans('m_status.unknown'), - 'map' => [ - '' => $this->translator->trans('m_status.unknown'), - 'announced' => $this->translator->trans('m_status.announced'), - 'active' => $this->translator->trans('m_status.active'), - 'nrfnd' => $this->translator->trans('m_status.nrfnd'), - 'eol' => $this->translator->trans('m_status.eol'), - 'discontinued' => $this->translator->trans('m_status.discontinued'), - ], + 'class' => ManufacturingStatus::class, + 'render' => function(?ManufacturingStatus $status, Part $context): string { + if (!$status) { + return ''; + } + + return $this->translator->trans($status->toTranslationKey()); + } , ]) ->add('manufacturer_product_number', TextColumn::class, [ 'label' => $this->translator->trans('part.table.mpn'), diff --git a/src/Entity/Parts/ManufacturingStatus.php b/src/Entity/Parts/ManufacturingStatus.php new file mode 100644 index 00000000..2b6de800 --- /dev/null +++ b/src/Entity/Parts/ManufacturingStatus.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\Parts; + +enum ManufacturingStatus: string +{ + /** Part has been announced, but is not in production yet */ + case ANNOUNCED = 'announced'; + /** Part is in production and will be for the foreseeable future */ + case ACTIVE = 'active'; + /** Not recommended for new designs. */ + case NRFND = 'nrfnd'; + /** End of life: Part will become discontinued soon */ + case EOL = 'eol'; + /** Part is obsolete/discontinued by the manufacturer. */ + case DISCONTINUED = 'discontinued'; + + /** Status not set */ + case NOT_SET = ''; + + public function toTranslationKey(): string + { + return match ($this) { + self::ANNOUNCED => 'm_status.announced', + self::ACTIVE => 'm_status.active', + self::NRFND => 'm_status.nrfnd', + self::EOL => 'm_status.eol', + self::DISCONTINUED => 'm_status.discontinued', + self::NOT_SET => '', + }; + } +} \ No newline at end of file diff --git a/src/Entity/Parts/PartTraits/ManufacturerTrait.php b/src/Entity/Parts/PartTraits/ManufacturerTrait.php index 81ab8ac9..71036d8c 100644 --- a/src/Entity/Parts/PartTraits/ManufacturerTrait.php +++ b/src/Entity/Parts/PartTraits/ManufacturerTrait.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Entity\Parts\PartTraits; +use App\Entity\Parts\ManufacturingStatus; use Doctrine\DBAL\Types\Types; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; @@ -60,12 +61,11 @@ trait ManufacturerTrait protected string $manufacturer_product_number = ''; /** - * @var string|null The production status of this part. Can be one of the specified ones. + * @var ManufacturingStatus|null The production status of this part. Can be one of the specified ones. */ - #[Assert\Choice(['announced', 'active', 'nrfnd', 'eol', 'discontinued', ''])] #[Groups(['extended', 'full', 'import'])] - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - protected ?string $manufacturing_status = ''; + #[ORM\Column(type: Types::STRING, length: 255, nullable: true, enumType: ManufacturingStatus::class)] + protected ?ManufacturingStatus $manufacturing_status = ManufacturingStatus::NOT_SET; /** * Get the link to the website of the article on the manufacturers website @@ -113,9 +113,9 @@ trait ManufacturerTrait * * "eol": Part will become discontinued soon * * "discontinued": Part is obsolete/discontinued by the manufacturer. * - * @return string + * @return ManufacturingStatus|null */ - public function getManufacturingStatus(): ?string + public function getManufacturingStatus(): ?ManufacturingStatus { return $this->manufacturing_status; } @@ -124,9 +124,9 @@ trait ManufacturerTrait * Sets the manufacturing status for this part * See getManufacturingStatus() for valid values. * - * @return Part + * @return $this */ - public function setManufacturingStatus(string $manufacturing_status): self + public function setManufacturingStatus(ManufacturingStatus $manufacturing_status): self { $this->manufacturing_status = $manufacturing_status; diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 309197ab..1fbaa55e 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form\Part; +use App\Entity\Parts\ManufacturingStatus; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\PartAttachment; use App\Entity\Parameters\PartParameter; @@ -42,6 +43,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -62,14 +64,6 @@ class PartBaseType extends AbstractType $part = $builder->getData(); $new_part = null === $part->getID(); - $status_choices = [ - 'm_status.unknown' => '', - 'm_status.announced' => 'announced', - 'm_status.active' => 'active', - 'm_status.nrfnd' => 'nrfnd', - 'm_status.eol' => 'eol', - 'm_status.discontinued' => 'discontinued', - ]; //Common section $builder @@ -140,9 +134,10 @@ class PartBaseType extends AbstractType 'empty_data' => '', 'label' => 'part.edit.mpn', ]) - ->add('manufacturing_status', ChoiceType::class, [ + ->add('manufacturing_status', EnumType::class, [ 'label' => 'part.edit.manufacturing_status', - 'choices' => $status_choices, + 'class' => ManufacturingStatus::class, + 'choice_label' => fn (ManufacturingStatus $status) => $status->toTranslationKey(), 'required' => false, ]); diff --git a/src/Twig/TwigCoreExtension.php b/src/Twig/TwigCoreExtension.php index 1cb7f1dc..b77ff28b 100644 --- a/src/Twig/TwigCoreExtension.php +++ b/src/Twig/TwigCoreExtension.php @@ -55,6 +55,7 @@ final class TwigCoreExtension extends AbstractExtension new TwigTest('instanceof', static fn($var, $instance) => $var instanceof $instance), /* Checks if a given variable is an object. E.g. `x is object` */ new TwigTest('object', static fn($var): bool => is_object($var)), + new TwigTest('enum', fn($var) => $var instanceof \UnitEnum), ]; } diff --git a/templates/helper.twig b/templates/helper.twig index 8388d551..d0ea72be 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -37,6 +37,10 @@ {% endmacro %} {% macro m_status_to_badge(status, class="badge") %} + {% if status is enum %} + {% set status = status.value %} + {% endif %} + {% if status is not empty %} {% set color = " bg-secondary" %} From e0301f096ff99814080141e47569f635af7e15fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 9 Jul 2023 14:27:41 +0200 Subject: [PATCH 02/34] Added an very basic system to configure info providers --- config/packages/http_client.yaml | 17 +++ config/services.yaml | 10 ++ src/Controller/InfoProviderController.php | 49 +++++++ .../DTOs/SearchResultDTO.php | 52 ++++++++ .../InfoProviderSystem/ProviderRegistry.php | 107 ++++++++++++++++ .../Providers/DigikeyProvider.php | 121 ++++++++++++++++++ .../Providers/InfoProviderInterface.php | 72 +++++++++++ .../Providers/ProviderCapabilities.php | 67 ++++++++++ .../Providers/TestProvider.php | 61 +++++++++ .../info_providers/providers.macro.html.twig | 52 ++++++++ .../providers_list/providers_list.html.twig | 33 +++++ translations/messages.en.xlf | 48 +++++++ 12 files changed, 689 insertions(+) create mode 100644 config/packages/http_client.yaml create mode 100644 src/Controller/InfoProviderController.php create mode 100644 src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php create mode 100644 src/Services/InfoProviderSystem/ProviderRegistry.php create mode 100644 src/Services/InfoProviderSystem/Providers/DigikeyProvider.php create mode 100644 src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php create mode 100644 src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php create mode 100644 src/Services/InfoProviderSystem/Providers/TestProvider.php create mode 100644 templates/info_providers/providers.macro.html.twig create mode 100644 templates/info_providers/providers_list/providers_list.html.twig diff --git a/config/packages/http_client.yaml b/config/packages/http_client.yaml new file mode 100644 index 00000000..9489e92b --- /dev/null +++ b/config/packages/http_client.yaml @@ -0,0 +1,17 @@ +framework: + http_client: + default_options: + headers: + 'User-Agent': 'Part-DB' + + + scoped_clients: + digikey.client: + base_uri: 'https://sandbox-api.digikey.com' + auth_bearer: '%env(PROVIDER_DIGIKEY_TOKEN)%' + headers: + X-DIGIKEY-Client-Id: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' + X-DIGIKEY-Locale-Site: 'DE' + X-DIGIKEY-Locale-Language: 'de' + X-DIGIKEY-Locale-Currency: '%partdb.default_currency%' + X-DIGIKEY-Customer-Id: 0 \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 300497de..6f939198 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,9 @@ services: App\Services\LabelSystem\PlaceholderProviders\PlaceholderProviderInterface: tags: ['app.label_placeholder_provider'] + App\Services\InfoProviderSystem\Providers\InfoProviderInterface: + tags: ['app.info_provider'] + # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: @@ -234,6 +237,13 @@ services: $rootNodeExpandedByDefault: '%partdb.sidebar.root_expanded%' $rootNodeEnabled: '%partdb.sidebar.root_node_enable%' + #################################################################################################################### + # Part info provider system + #################################################################################################################### + App\Services\InfoProviderSystem\ProviderRegistry: + arguments: + $providers: !tagged_iterator 'app.info_provider' + #################################################################################################################### # Symfony overrides #################################################################################################################### diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php new file mode 100644 index 00000000..11935f0d --- /dev/null +++ b/src/Controller/InfoProviderController.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +#[Route('/tools/info_providers')] +class InfoProviderController extends AbstractController +{ + + #[Route('/providers', name: 'info_providers_list')] + public function listProviders(ProviderRegistry $providerRegistry): Response + { + return $this->render('info_providers/providers_list/providers_list.html.twig', [ + 'active_providers' => $providerRegistry->getActiveProviders(), + 'disabled_providers' => $providerRegistry->getDisabledProviders(), + ]); + } + + #[Route('/search', name: 'info_providers_search')] + public function search(): Response + { + + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php new file mode 100644 index 00000000..5c4cb97f --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -0,0 +1,52 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; + +class SearchResultDTO +{ + public function __construct( + /** @var string The provider key (e.g. "digikey") */ + public readonly string $provider_key, + /** @var string The ID which identifies the part in the provider system */ + public readonly string $provider_id, + /** @var string The name of the part */ + public readonly string $name, + /** @var string A short description of the part */ + public readonly string $description, + /** @var string|null The manufacturer of the part */ + public readonly ?string $manufacturer = null, + /** @var string|null The manufacturer part number */ + public readonly ?string $mpn = null, + /** @var string|null An URL to a preview image */ + public readonly ?string $preview_image_url = null, + /** @var ManufacturingStatus|null The manufacturing status of the part */ + public readonly ?ManufacturingStatus $manufacturing_status = null, + /** @var string|null A link to the part on the providers page */ + public readonly ?string $provider_url = null, + ) { + + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php new file mode 100644 index 00000000..2b9ef43b --- /dev/null +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -0,0 +1,107 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +class ProviderRegistry +{ + + /** + * @var InfoProviderInterface[] The info providers index by their keys + * @psalm-var array + */ + private array $providers_by_name = []; + + /** + * @var InfoProviderInterface[] The enabled providers indexed by their keys + * @psalm-var array + */ + private array $providers_active = []; + + /** + * @var InfoProviderInterface[] The disabled providers indexed by their keys + * @psalm-var array + */ + private array $providers_disabled = []; + + /** + * @param iterable $providers + */ + public function __construct(private readonly iterable $providers) + { + foreach ($providers as $provider) { + $key = $provider->getProviderKey(); + + if (isset($this->providers_by_name[$key])) { + throw new \LogicException("Provider with key $key already registered"); + } + + $this->providers_by_name[$key] = $provider; + if ($provider->isActive()) { + $this->providers_active[$key] = $provider; + } else { + $this->providers_disabled[$key] = $provider; + } + } + } + + /** + * Returns an array of all registered providers (enabled and disabled) + * @return InfoProviderInterface[] + */ + public function getProviders(): array + { + return $this->providers_by_name; + } + + /** + * Returns the provider identified by the given key + * @param string $key + * @return InfoProviderInterface + * @throws \InvalidArgumentException If the provider with the given key does not exist + */ + public function getProviderByKey(string $key): InfoProviderInterface + { + return $this->providers_by_name[$key] ?? throw new \InvalidArgumentException("Provider with key $key not found"); + } + + /** + * Returns an array of all active providers + * @return InfoProviderInterface[] + */ + public function getActiveProviders(): array + { + return $this->providers_active; + } + + /** + * Returns an array of all disabled providers + * @return InfoProviderInterface[] + */ + public function getDisabledProviders(): array + { + return $this->providers_disabled; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php new file mode 100644 index 00000000..6c85e36b --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -0,0 +1,121 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class DigikeyProvider implements InfoProviderInterface +{ + + public function __construct(private readonly HttpClientInterface $digikeyClient) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'DigiKey', + 'description' => 'This provider uses the DigiKey API to search for parts.', + 'url' => 'https://www.digikey.com/', + ]; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } + + public function getProviderKey(): string + { + return 'digikey'; + } + + public function isActive(): bool + { + return true; + } + + public function searchByKeyword(string $keyword): array + { + $request = [ + 'Keywords' => $keyword, + 'RecordCount' => 50, + 'RecordStartPosition' => 0, + 'ExcludeMarketPlaceProducts' => 'true', + ]; + + $response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [ + 'json' => $request, + ]); + + $response_array = $response->toArray(); + + + $result = []; + $products = $response_array['Products']; + foreach ($products as $product) { + $result[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['DigiKeyPartNumber'], + name: $product['ManufacturerPartNumber'], + description: $product['ProductDescription'], + manufacturer: $product['Manufacturer']['Value'] ?? null, + mpn: $product['ManufacturerPartNumber'], + preview_image_url: $product['PrimaryPhoto'] ?? null, + manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']), + provider_url: 'https://digikey.com'.$product['ProductUrl'], + ); + } + + return $result; + } + + /** + * Converts the product status from the Digikey API to the manufacturing status used in Part-DB + * @param string|null $dk_status + * @return ManufacturingStatus|null + */ + private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus + { + return match ($dk_status) { + null => null, + 'Active' => ManufacturingStatus::ACTIVE, + 'Obsolete' => ManufacturingStatus::DISCONTINUED, + 'Discontinued at Digi-Key' => ManufacturingStatus::EOL, + 'Last Time Buy' => ManufacturingStatus::EOL, + 'Not For New Designs' => ManufacturingStatus::NRFND, + 'Preliminary' => ManufacturingStatus::ANNOUNCED, + default => ManufacturingStatus::NOT_SET, + }; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php new file mode 100644 index 00000000..97d8087b --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; + +interface InfoProviderInterface +{ + + /** + * Get information about this provider + * + * @return array An associative array with the following keys (? means optional): + * - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated + * - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated + * - logo?: The logo of the provider (e.g. "digikey.png") + * - url?: The url of the provider (e.g. "https://www.digikey.com") + * - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it + * + * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string } + */ + public function getProviderInfo(): array; + + /** + * Returns a unique key for this provider, which will be saved into the database + * and used to identify the provider + * @return string A unique key for this provider (e.g. "digikey") + */ + public function getProviderKey(): string; + + /** + * Checks if this provider is enabled or not (meaning that it can be used for searching) + * @return bool True if the provider is enabled, false otherwise + */ + public function isActive(): bool; + + /** + * Searches for a keyword and returns a list of search results + * @param string $keyword The keyword to search for + * @return SearchResultDTO[] A list of search results + */ + public function searchByKeyword(string $keyword): array; + + /** + * A list of capabilities this provider supports (which kind of data it can provide). + * Not every part have to contain all of these data, but the provider should be able to provide them in general. + * Currently, this list is purely informational and not used in functional checks. + * @return ProviderCapabilities[] + */ + public function getCapabilities(): array; +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php new file mode 100644 index 00000000..fd67cd2c --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +/** + * This enum contains all capabilities (which data it can provide) a provider can have. + */ +enum ProviderCapabilities +{ + /** Basic information about a part, like the name, description, part number, manufacturer etc */ + case BASIC; + + /** Information about the footprint of a part */ + case FOOTPRINT; + + /** Provider can provide a picture for a part */ + case PICTURE; + + /** Provider can provide datasheets for a part */ + case DATASHEET; + + /** Provider can provide prices for a part */ + case PRICE; + + public function getTranslationKey(): string + { + return 'info_providers.capabilities.' . match($this) { + self::BASIC => 'basic', + self::FOOTPRINT => 'footprint', + self::PICTURE => 'picture', + self::DATASHEET => 'datasheet', + self::PRICE => 'price', + }; + } + + public function getFAIconClass(): string + { + return 'fa-solid ' . match($this) { + self::BASIC => 'fa-info-circle', + self::FOOTPRINT => 'fa-microchip', + self::PICTURE => 'fa-image', + self::DATASHEET => 'fa-file-alt', + self::PRICE => 'fa-money-bill-wave', + }; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php new file mode 100644 index 00000000..4147b5b4 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +class TestProvider implements InfoProviderInterface +{ + + public function getProviderInfo(): array + { + return [ + 'name' => 'Test Provider', + 'description' => 'This is a test provider', + //'url' => 'https://example.com', + 'disabled_help' => 'This provider is disabled for testing purposes' + ]; + } + + public function getProviderKey(): string + { + return 'test'; + } + + public function isActive(): bool + { + return false; + } + + public function searchByKeyword(string $keyword): array + { + // TODO: Implement searchByKeyword() method. + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ]; + } +} \ No newline at end of file diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig new file mode 100644 index 00000000..4f8dc3b8 --- /dev/null +++ b/templates/info_providers/providers.macro.html.twig @@ -0,0 +1,52 @@ +{% macro provider_info_table(providers) %} + + + {% for provider in providers %} + {# @var provider \App\Services\InfoProviderSystem\Providers\InfoProviderInterface #} + + + + {% endfor %} + +
+
+
+
+ {% if provider.providerInfo.url is defined and provider.providerInfo.url is not empty %} + {{ provider.providerInfo.name }} + {% else %} + {{ provider.providerInfo.name | trans }} + {% endif %} + +
+
+ {% if provider.providerInfo.description is defined and provider.providerInfo.description is not null %} + {{ provider.providerInfo.description | trans }} + {% endif %} +
+ +
+
+ {% for capability in provider.capabilities %} + {# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #} + + + {{ capability.translationKey|trans }} + + {% endfor %} +
+
+ {% if provider.active == false %} +
+
+ {% trans %}info_providers.providers_list.disabled{% endtrans %} + {% if provider.providerInfo.disabled_help is defined and provider.providerInfo.disabled_help is not empty %} +
+ {{ provider.providerInfo.disabled_help|trans }} + {% endif %} +
+
+ + {% endif %} +
+{% endmacro %} \ No newline at end of file diff --git a/templates/info_providers/providers_list/providers_list.html.twig b/templates/info_providers/providers_list/providers_list.html.twig new file mode 100644 index 00000000..18037bd2 --- /dev/null +++ b/templates/info_providers/providers_list/providers_list.html.twig @@ -0,0 +1,33 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} + +{% block title %}{% trans %}info_providers.providers_list.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}info_providers.providers_list.title{% endtrans %} +{% endblock %} + +{% block card_content %} + +
+
+ {{ providers_macro.provider_info_table(active_providers) }} +
+
+ {{ providers_macro.provider_info_table(disabled_providers) }} +
+
+ +{% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ffbd71df..be059355 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11435,5 +11435,53 @@ Please note, that you can not impersonate a disabled user. If you try you will g User impersonated + + + info_providers.providers_list.title + Info providers + + + + + info_providers.providers_list.active + Active + + + + + info_providers.providers_list.disabled + Disabled + + + + + info_providers.capabilities.basic + Basic + + + + + info_providers.capabilities.footprint + Footprint + + + + + info_providers.capabilities.picture + Picture + + + + + info_providers.capabilities.datasheet + Datasheets + + + + + info_providers.capabilities.price + Prices + + From 93a170a8934a7785ea086d0323779afb7503efad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 9 Jul 2023 17:55:41 +0200 Subject: [PATCH 03/34] Added basic search system in info providers --- src/Controller/InfoProviderController.php | 20 ++++++- .../InfoProviderSystem/PartSearchType.php | 39 ++++++++++++ .../InfoProviderSystem/ProviderSelectType.php | 57 ++++++++++++++++++ .../InfoProviderSystem/PartInfoRetriever.php | 60 +++++++++++++++++++ .../Providers/TestProvider.php | 2 +- .../search/part_search.html.twig | 46 ++++++++++++++ 6 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/Form/InfoProviderSystem/PartSearchType.php create mode 100644 src/Form/InfoProviderSystem/ProviderSelectType.php create mode 100644 src/Services/InfoProviderSystem/PartInfoRetriever.php create mode 100644 templates/info_providers/search/part_search.html.twig diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index 11935f0d..a755d093 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -23,8 +23,11 @@ declare(strict_types=1); namespace App\Controller; +use App\Form\InfoProviderSystem\PartSearchType; +use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -42,8 +45,23 @@ class InfoProviderController extends AbstractController } #[Route('/search', name: 'info_providers_search')] - public function search(): Response + public function search(Request $request, PartInfoRetriever $infoRetriever): Response { + $form = $this->createForm(PartSearchType::class); + $form->handleRequest($request); + $results = null; + + if ($form->isSubmitted() && $form->isValid()) { + $keyword = $form->get('keyword')->getData(); + $providers = $form->get('providers')->getData(); + + $results = $infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); + } + + return $this->render('info_providers/search/part_search.html.twig', [ + 'form' => $form, + 'results' => $results, + ]); } } \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/PartSearchType.php b/src/Form/InfoProviderSystem/PartSearchType.php new file mode 100644 index 00000000..5bbbf156 --- /dev/null +++ b/src/Form/InfoProviderSystem/PartSearchType.php @@ -0,0 +1,39 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\SearchType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; + +class PartSearchType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('keyword', SearchType::class); + $builder->add('providers', ProviderSelectType::class); + $builder->add('submit', SubmitType::class); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/ProviderSelectType.php b/src/Form/InfoProviderSystem/ProviderSelectType.php new file mode 100644 index 00000000..6ebe663d --- /dev/null +++ b/src/Form/InfoProviderSystem/ProviderSelectType.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\InfoProviderSystem; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Hoa\Compiler\Llk\Rule\Choice; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProviderSelectType extends AbstractType +{ + public function __construct(private readonly ProviderRegistry $providerRegistry) + { + + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'choices' => $this->providerRegistry->getActiveProviders(), + 'choice_label' => ChoiceList::label($this, fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']), + 'choice_value' => ChoiceList::value($this, fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()), + + 'multiple' => true, + ]); + } + +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php new file mode 100644 index 00000000..5e7ecc31 --- /dev/null +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +class PartInfoRetriever +{ + public function __construct(private readonly ProviderRegistry $provider_registry) + { + } + + /** + * Search for a keyword in the given providers + * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances + * @param string $keyword The keyword to search for + * @return SearchResultDTO[] The search results + */ + public function searchByKeyword(string $keyword, array $providers): array + { + $results = []; + + foreach ($providers as $provider) { + if (is_string($provider)) { + $provider = $this->provider_registry->getProviderByKey($provider); + } + + if (!$provider instanceof InfoProviderInterface) { + throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!"); + } + + /** @noinspection SlowArrayOperationsInLoopInspection */ + $results = array_merge($results, $provider->searchByKeyword($keyword)); + } + + return $results; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index 4147b5b4..04018f32 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -43,7 +43,7 @@ class TestProvider implements InfoProviderInterface public function isActive(): bool { - return false; + return true; } public function searchByKeyword(string $keyword): array diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig new file mode 100644 index 00000000..79d18c9a --- /dev/null +++ b/templates/info_providers/search/part_search.html.twig @@ -0,0 +1,46 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} +{% import "helper.twig" as helper %} + +{% block title %}{% trans %}info_providers.providers_list.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}info_providers.providers_list.title{% endtrans %} +{% endblock %} + +{% block card_content %} + + {{ form(form) }} + + {% if results is not null %} + + + + + + + + + + + + + + {% for result in results %} + + + + + + + + + + {% endfor %} + + +
NameDescriptionManufactuerMPNStatusProvider
{{ result.name }}{{ result.description }}{{ result.manufacturer ?? '' }}{{ result.mpn ?? '' }}{{ helper.m_status_to_badge(result.manufacturing_status) }}{{ result.provider_key }}: {{ result.provider_id }}
+ {% endif %} + +{% endblock %} From 538476be99f8d49997f94f599a0cb54d0aba2e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 9 Jul 2023 18:51:54 +0200 Subject: [PATCH 04/34] Added a info provider for element14/Farnell --- config/services.yaml | 4 + .../Providers/Element14Provider.php | 155 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/Services/InfoProviderSystem/Providers/Element14Provider.php diff --git a/config/services.yaml b/config/services.yaml index 6f939198..cec5019d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -244,6 +244,10 @@ services: arguments: $providers: !tagged_iterator 'app.info_provider' + App\Services\InfoProviderSystem\Providers\Element14Provider: + arguments: + $api_key: '%env(PROVIDER_ELEMENT14_KEY)%' + #################################################################################################################### # Symfony overrides #################################################################################################################### diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php new file mode 100644 index 00000000..0250d45e --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -0,0 +1,155 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Form\InfoProviderSystem\ProviderSelectType; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class Element14Provider implements InfoProviderInterface +{ + + private const ENDPOINT_URL = 'https://api.element14.com/catalog/products'; + private const FARNELL_STORE_ID = 'de.farnell.com'; + private const API_VERSION_NUMBER = '1.2'; + private const NUMBER_OF_RESULTS = 20; + + public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'Farnell element14', + 'description' => 'This provider uses the Farnell element14 API to search for parts.', + 'url' => 'https://www.element14.com/', + ]; + } + + public function getProviderKey(): string + { + return 'element14'; + } + + public function isActive(): bool + { + return !empty($this->api_key); + } + + private function queryByTerm(string $term, string $responseGroup = 'large'): array + { + $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ + 'query' => [ + 'term' => $term, + 'storeInfo.id' => self::FARNELL_STORE_ID, + 'resultsSettings.offset' => 0, + 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, + 'resultsSettings.responseGroup' => $responseGroup, + 'callInfo.apiKey' => $this->api_key, + 'callInfo.responseDataFormat' => 'json', + 'callInfo.version' => self::API_VERSION_NUMBER, + ], + ]); + + return $response->toArray(); + + } + + private function toImageUrl(?array $image): ?string + { + if ($image === null || count($image) === 0) { + return null; + } + + //See Constructing an Image URL: https://partner.element14.com/docs/Product_Search_API_REST__Description + $locale = 'en_GB'; + if ($image['vrntPath'] === 'nio/') { + $locale = 'en_US'; + } + + return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; + } + + private function displayNameToDescription(string $display_name, string $mpn): string + { + //Try to find the position of the '-' after the MPN + $pos = strpos($display_name, $mpn . ' - '); + if ($pos === false) { + return $display_name; + } + + //Remove the MPN and the '-' from the display name + return substr($display_name, $pos + strlen($mpn) + 3); + } + + private function releaseStatusCodeToManufacturingStatus(?int $releaseStatusCode): ?ManufacturingStatus + { + if ($releaseStatusCode === null) { + return null; + } + + return match ($releaseStatusCode) { + 1 => ManufacturingStatus::ANNOUNCED, + 2,4 => ManufacturingStatus::ACTIVE, + 6 => ManufacturingStatus::EOL, + 7 => ManufacturingStatus::DISCONTINUED, + default => ManufacturingStatus::NOT_SET + }; + } + + public function searchByKeyword(string $keyword): array + { + $response = $this->queryByTerm('any:' . $keyword); + $products = $response['keywordSearchReturn']['products'] ?? []; + + $result = []; + + foreach ($products as $product) { + $result[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), provider_id: $product['sku'], + name: $product['translatedManufacturerPartNumber'], + description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), + manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, + mpn: $product['translatedManufacturerPartNumber'], + preview_image_url: $this->toImageUrl($product['image'] ?? null), + manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), + provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'] + ); + } + + return $result; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ]; + } +} \ No newline at end of file From 716a56979d6e435c45a3164ef107a9448b5f0c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 9 Jul 2023 23:31:40 +0200 Subject: [PATCH 05/34] Added basic possibilty to create parts based on infoProviders --- src/Controller/InfoProviderController.php | 89 +++++++++++++- .../InfoProviderSystem/DTOs/FileDTO.php | 34 ++++++ .../InfoProviderSystem/DTOs/ParameterDTO.php | 49 ++++++++ .../InfoProviderSystem/DTOs/PartDetailDTO.php | 61 ++++++++++ .../DTOs/SearchResultDTO.php | 2 + .../DTOtoEntityConverter.php | 98 +++++++++++++++ .../InfoProviderSystem/PartInfoRetriever.php | 27 ++++- .../Providers/DigikeyProvider.php | 6 + .../Providers/Element14Provider.php | 114 +++++++++++++++--- .../Providers/InfoProviderInterface.php | 8 ++ .../Providers/TestProvider.php | 7 ++ .../search/part_search.html.twig | 6 + 12 files changed, 476 insertions(+), 25 deletions(-) create mode 100644 src/Services/InfoProviderSystem/DTOs/FileDTO.php create mode 100644 src/Services/InfoProviderSystem/DTOs/ParameterDTO.php create mode 100644 src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php create mode 100644 src/Services/InfoProviderSystem/DTOtoEntityConverter.php diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index a755d093..dbcd6a2a 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -23,29 +23,42 @@ declare(strict_types=1); namespace App\Controller; +use App\Exceptions\AttachmentDownloadException; use App\Form\InfoProviderSystem\PartSearchType; +use App\Form\Part\PartBaseType; +use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\LogSystem\EventCommentHelper; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; #[Route('/tools/info_providers')] class InfoProviderController extends AbstractController { + public function __construct(private readonly ProviderRegistry $providerRegistry, + private readonly PartInfoRetriever $infoRetriever, private readonly EventCommentHelper $commentHelper) + { + + } + #[Route('/providers', name: 'info_providers_list')] - public function listProviders(ProviderRegistry $providerRegistry): Response + public function listProviders(): Response { return $this->render('info_providers/providers_list/providers_list.html.twig', [ - 'active_providers' => $providerRegistry->getActiveProviders(), - 'disabled_providers' => $providerRegistry->getDisabledProviders(), + 'active_providers' => $this->providerRegistry->getActiveProviders(), + 'disabled_providers' => $this->providerRegistry->getDisabledProviders(), ]); } #[Route('/search', name: 'info_providers_search')] - public function search(Request $request, PartInfoRetriever $infoRetriever): Response + public function search(Request $request): Response { $form = $this->createForm(PartSearchType::class); $form->handleRequest($request); @@ -56,7 +69,7 @@ class InfoProviderController extends AbstractController $keyword = $form->get('keyword')->getData(); $providers = $form->get('providers')->getData(); - $results = $infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); + $results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); } return $this->render('info_providers/search/part_search.html.twig', [ @@ -64,4 +77,70 @@ class InfoProviderController extends AbstractController 'results' => $results, ]); } + + #[Route('/part/{providerKey}/{providerId}/create', name: 'info_providers_create_part')] + public function createPart(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, + AttachmentSubmitHandler $attachmentSubmitHandler, string $providerKey, string $providerId): Response + { + + $new_part = $this->infoRetriever->createPart($providerKey, $providerId); + + $form = $this->createForm(PartBaseType::class, $new_part); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + //Upload passed files + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var FormInterface $attachment */ + $options = [ + 'secure_attachment' => $attachment['secureFile']->getData(), + 'download_url' => $attachment['downloadURL']->getData(), + ]; + + try { + $attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); + } catch (AttachmentDownloadException $attachmentDownloadException) { + $this->addFlash( + 'error', + $translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() + ); + } + } + + $this->commentHelper->setMessage($form['log_comment']->getData()); + + $em->persist($new_part); + $em->flush(); + $this->addFlash('success', 'part.created_flash'); + + //If a redirect URL was given, redirect there + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + + //Redirect to clone page if user wished that... + //@phpstan-ignore-next-line + if ('save_and_clone' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_clone', ['id' => $new_part->getID()]); + } + //@phpstan-ignore-next-line + if ('save_and_new' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_new'); + } + + return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', 'part.created_flash.invalid'); + } + + return $this->render('parts/edit/new_part.html.twig', + [ + 'part' => $new_part, + 'form' => $form, + ]); + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php new file mode 100644 index 00000000..7c005b9b --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -0,0 +1,34 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class FileDTO +{ + public function __construct( + /** The URL where to get this file */ + public readonly string $url, + /** Optionally the name of this file */ + public readonly ?string $name = null, + ) {} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php new file mode 100644 index 00000000..9613a859 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class ParameterDTO +{ + public function __construct( + public readonly string $name, + public readonly ?string $value_text = null, + public readonly ?float $value_typ = null, + public readonly ?float $value_min = null, + public readonly ?float $value_max = null, + public readonly ?string $unit = null, + public readonly ?string $symbol = null, + public readonly ?string $group = null, + ) { + + } + + public static function parseValueField(string $name, string|float $value, ?string $unit = null, ?string $symbol = null, ?string $group = null): self + { + if (is_float($value) || is_numeric($value)) { + return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol); + } + + return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php new file mode 100644 index 00000000..13bb93d8 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; +use Hoa\Zformat\Parameter; + +class PartDetailDTO extends SearchResultDTO +{ + public function __construct( + string $provider_key, + string $provider_id, + string $name, + string $description, + ?string $manufacturer = null, + ?string $mpn = null, + ?string $preview_image_url = null, + ?ManufacturingStatus $manufacturing_status = null, + ?string $provider_url = null, + ?string $footprint = null, + public readonly ?string $notes = null, + /** @var FileDTO[]|null */ + public readonly ?array $datasheets = null, + /** @var ParameterDTO[]|null */ + public readonly ?array $parameters = null, + ) { + parent::__construct( + provider_key: $provider_key, + provider_id: $provider_id, + name: $name, + description: $description, + manufacturer: $manufacturer, + mpn: $mpn, + preview_image_url: $preview_image_url, + manufacturing_status: $manufacturing_status, + provider_url: $provider_url, + footprint: $footprint, + ); + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 5c4cb97f..3687dca9 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -46,6 +46,8 @@ class SearchResultDTO public readonly ?ManufacturingStatus $manufacturing_status = null, /** @var string|null A link to the part on the providers page */ public readonly ?string $provider_url = null, + /** @var string|null A footprint representation of the providers page */ + public readonly ?string $footprint = null, ) { } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php new file mode 100644 index 00000000..749467e5 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -0,0 +1,98 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\ManufacturingStatus; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; + +/** + * This class converts DTOs to entities which can be persisted in the DB + */ +class DTOtoEntityConverter +{ + + public function convertParameter(ParameterDTO $dto, PartParameter $entity = new PartParameter()): PartParameter + { + $entity->setName($dto->name); + $entity->setValueText($dto->value_text ?? ''); + $entity->setValueTypical($dto->value_typ); + $entity->setValueMin($dto->value_min); + $entity->setValueMax($dto->value_max); + $entity->setUnit($dto->unit ?? ''); + $entity->setSymbol($dto->symbol ?? ''); + $entity->setGroup($dto->group ?? ''); + + return $entity; + } + + public function convertFile(FileDTO $dto, PartAttachment $entity = new PartAttachment()): PartAttachment + { + $entity->setURL($dto->url); + + //If no name is given, try to extract the name from the URL + if (empty($dto->name)) { + $entity->setName(basename($dto->url)); + } else { + $entity->setName($dto->name); + } + + return $entity; + } + + /** + * Converts a PartDetailDTO to a Part entity + * @param PartDetailDTO $dto + * @param Part $entity The part entity to fill + * @return Part + */ + public function convertPart(PartDetailDTO $dto, Part $entity = new Part()): Part + { + $entity->setName($dto->name); + $entity->setDescription($dto->description ?? ''); + $entity->setComment($dto->notes ?? ''); + + $entity->setManufacturerProductNumber($dto->mpn ?? ''); + $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); + + //Add parameters + foreach ($dto->parameters ?? [] as $parameter) { + $entity->addParameter($this->convertParameter($parameter)); + } + + //Add datasheets + foreach ($dto->datasheets ?? [] as $datasheet) { + $entity->addAttachment($this->convertFile($datasheet)); + } + + return $entity; + } + +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 5e7ecc31..a010fbcd 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -23,12 +23,14 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; class PartInfoRetriever { - public function __construct(private readonly ProviderRegistry $provider_registry) + public function __construct(private readonly ProviderRegistry $provider_registry, private readonly DTOtoEntityConverter $dto_to_entity_converter) { } @@ -57,4 +59,27 @@ class PartInfoRetriever return $results; } + + /** + * Retrieves the details for a part from the given provider with the given (provider) part id + * @param string $provider_key + * @param string $part_id + * @return + */ + public function getDetails(string $provider_key, string $part_id): PartDetailDTO + { + return $this->provider_registry->getProviderByKey($provider_key)->getDetails($part_id); + } + + public function getDetailsForSearchResult(SearchResultDTO $search_result): PartDetailDTO + { + return $this->getDetails($search_result->provider_key, $search_result->provider_id); + } + + public function createPart(string $provider_key, string $part_id): Part + { + $details = $this->getDetails($provider_key, $part_id); + + return $this->dto_to_entity_converter->convertPart($details); + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index 6c85e36b..26cea80c 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -118,4 +119,9 @@ class DigikeyProvider implements InfoProviderInterface default => ManufacturingStatus::NOT_SET, }; } + + public function getDetails(string $id): PartDetailDTO + { + // TODO: Implement getDetails() method. + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 0250d45e..854b4a1b 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -25,6 +25,9 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; use App\Form\InfoProviderSystem\ProviderSelectType; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -36,6 +39,9 @@ class Element14Provider implements InfoProviderInterface private const API_VERSION_NUMBER = '1.2'; private const NUMBER_OF_RESULTS = 20; + private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', + 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; + public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key) { @@ -60,7 +66,11 @@ class Element14Provider implements InfoProviderInterface return !empty($this->api_key); } - private function queryByTerm(string $term, string $responseGroup = 'large'): array + /** + * @param string $term + * @return PartDetailDTO[] + */ + private function queryByTerm(string $term): array { $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ 'query' => [ @@ -68,17 +78,60 @@ class Element14Provider implements InfoProviderInterface 'storeInfo.id' => self::FARNELL_STORE_ID, 'resultsSettings.offset' => 0, 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, - 'resultsSettings.responseGroup' => $responseGroup, + 'resultsSettings.responseGroup' => 'large', 'callInfo.apiKey' => $this->api_key, 'callInfo.responseDataFormat' => 'json', 'callInfo.version' => self::API_VERSION_NUMBER, ], ]); - return $response->toArray(); + $arr = $response->toArray(); + if (isset($arr['keywordSearchReturn'])) { + $products = $arr['keywordSearchReturn']['products'] ?? []; + } elseif (isset($arr['premierFarnellPartNumberReturn'])) { + $products = $arr['premierFarnellPartNumberReturn']['products'] ?? []; + } else { + throw new \RuntimeException('Unknown response format'); + } + $result = []; + + foreach ($products as $product) { + $result[] = new PartDetailDTO( + provider_key: $this->getProviderKey(), provider_id: $product['sku'], + name: $product['translatedManufacturerPartNumber'], + description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), + manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, + mpn: $product['translatedManufacturerPartNumber'], + preview_image_url: $this->toImageUrl($product['image'] ?? null), + manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), + provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'], + datasheets: $this->parseDataSheets($product['datasheets'] ?? null), + parameters: $this->attributesToParameters($product['attributes'] ?? null), + ); + } + + return $result; } + /** + * @param mixed[]|null $datasheets + * @return FileDTO[]|null Array of FileDTOs + */ + private function parseDataSheets(?array $datasheets): ?array + { + if ($datasheets === null || count($datasheets) === 0) { + return null; + } + + $result = []; + foreach ($datasheets as $datasheet) { + $result[] = new FileDTO(url: $datasheet['url'], name: $datasheet['description']); + } + + return $result; + } + private function toImageUrl(?array $image): ?string { if ($image === null || count($image) === 0) { @@ -94,6 +147,33 @@ class Element14Provider implements InfoProviderInterface return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; } + /** + * @param array|null $attributes + * @return ParameterDTO[]|null + */ + private function attributesToParameters(?array $attributes): ?array + { + $result = []; + + foreach ($attributes as $attribute) { + $group = null; + + //Check if the attribute is a compliance attribute, they get assigned to the compliance group + if (in_array($attribute['attributeLabel'], self::COMPLIANCE_ATTRIBUTES, true)) { + $group = 'Compliance'; + } + + //tariffCode is a special case, we prepend a # to prevent conversion to float + if (in_array($attribute['attributeLabel'], ['tariffCode', 'hazardCode'])) { + $attribute['attributeValue'] = '#' . $attribute['attributeValue']; + } + + $result[] = ParameterDTO::parseValueField(name: $attribute['attributeLabel'], value: $attribute['attributeValue'], unit: $attribute['attributeUnit'] ?? null, group: $group); + } + + return $result; + } + private function displayNameToDescription(string $display_name, string $mpn): string { //Try to find the position of the '-' after the MPN @@ -123,25 +203,21 @@ class Element14Provider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { - $response = $this->queryByTerm('any:' . $keyword); - $products = $response['keywordSearchReturn']['products'] ?? []; + return $this->queryByTerm('any:' . $keyword); + } - $result = []; - - foreach ($products as $product) { - $result[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), provider_id: $product['sku'], - name: $product['translatedManufacturerPartNumber'], - description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), - manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, - mpn: $product['translatedManufacturerPartNumber'], - preview_image_url: $this->toImageUrl($product['image'] ?? null), - manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), - provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'] - ); + public function getDetails(string $id): PartDetailDTO + { + $tmp = $this->queryByTerm('id:' . $id); + if (count($tmp) === 0) { + throw new \RuntimeException('No part found with ID ' . $id); } - return $result; + if (count($tmp) > 1) { + throw new \RuntimeException('Multiple parts found with ID ' . $id); + } + + return $tmp[0]; } public function getCapabilities(): array diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index 97d8087b..61f79274 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; interface InfoProviderInterface @@ -62,6 +63,13 @@ interface InfoProviderInterface */ public function searchByKeyword(string $keyword): array; + /** + * Returns detailed information about the part with the given id + * @param string $id + * @return PartDetailDTO + */ + public function getDetails(string $id): PartDetailDTO; + /** * A list of capabilities this provider supports (which kind of data it can provide). * Not every part have to contain all of these data, but the provider should be able to provide them in general. diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index 04018f32..b680513e 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; + class TestProvider implements InfoProviderInterface { @@ -58,4 +60,9 @@ class TestProvider implements InfoProviderInterface ProviderCapabilities::FOOTPRINT, ]; } + + public function getDetails(string $id): PartDetailDTO + { + // TODO: Implement getDetails() method. + } } \ No newline at end of file diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index 79d18c9a..91f5cc70 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -24,6 +24,7 @@ MPN Status Provider + @@ -36,6 +37,11 @@ {{ result.mpn ?? '' }} {{ helper.m_status_to_badge(result.manufacturing_status) }} {{ result.provider_key }}: {{ result.provider_id }} + + + + + {% endfor %} From 6cd9640b30fe0fb7891a4634fa33e0f161f205ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 12 Jul 2023 23:43:16 +0200 Subject: [PATCH 06/34] Allow to automatically find or create entities from database based on info providers --- .../Helper/StructuralEntityChoiceLoader.php | 30 ++++++++- src/Form/Type/StructuralEntityType.php | 5 ++ .../StructuralDBElementRepository.php | 65 +++++++++++++++++++ .../DTOtoEntityConverter.php | 19 ++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php index c2d35d92..97230319 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\Form\Type\Helper; +use App\Entity\Base\AbstractStructuralDBElement; use App\Repository\StructuralDBElementRepository; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; @@ -32,13 +33,21 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader { private ?string $additional_element = null; + private ?AbstractStructuralDBElement $starting_element = null; + public function __construct(private readonly Options $options, private readonly NodesListBuilder $builder, private readonly EntityManagerInterface $entityManager) { } protected function loadChoices(): iterable { - $tmp = []; + //If the starting_element is set and not persisted yet, add it to the list + if ($this->starting_element !== null && $this->starting_element->getID() === null) { + $tmp = [$this->starting_element]; + } else { + $tmp = []; + } + if ($this->additional_element) { $tmp = $this->createNewEntitiesFromValue($this->additional_element); $this->additional_element = null; @@ -86,4 +95,23 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader return $this->additional_element; } + /** + * @return AbstractStructuralDBElement|null + */ + public function getStartingElement(): ?AbstractStructuralDBElement + { + return $this->starting_element; + } + + /** + * @param AbstractStructuralDBElement|null $starting_element + * @return StructuralEntityChoiceLoader + */ + public function setStartingElement(?AbstractStructuralDBElement $starting_element): StructuralEntityChoiceLoader + { + $this->starting_element = $starting_element; + return $this; + } + + } diff --git a/src/Form/Type/StructuralEntityType.php b/src/Form/Type/StructuralEntityType.php index 8afb6ce2..fbc294e4 100644 --- a/src/Form/Type/StructuralEntityType.php +++ b/src/Form/Type/StructuralEntityType.php @@ -122,6 +122,11 @@ class StructuralEntityType extends AbstractType public function modelTransform($value, array $options) { + $choice_loader = $options['choice_loader']; + if ($choice_loader instanceof StructuralEntityChoiceLoader) { + $choice_loader->setStartingElement($value); + } + return $value; } diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index c1882bda..48c10414 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -185,4 +185,69 @@ class StructuralDBElementRepository extends NamedDBElementRepository return $result; } + + /** + * Finds the element with the given name for the use with the InfoProvider System + * The name search is a bit more fuzzy than the normal findByName, because it is case-insensitive and ignores special characters. + * Also, it will try to find the element using the additional names field, of the elements. + * @param string $name + * @return AbstractStructuralDBElement|null + */ + public function findForInfoProvider(string $name): ?AbstractStructuralDBElement + { + //First try to find the element by name + $qb = $this->createQueryBuilder('e'); + //Use lowercase conversion to be case-insensitive + $qb->where($qb->expr()->like('LOWER(e.name)', 'LOWER(:name)')); + + $qb->setParameter('name', $name); + + $result = $qb->getQuery()->getResult(); + + if (count($result) === 1) { + return $result[0]; + } + + /*//If we have no result, try to find the element by additional names + $qb = $this->createQueryBuilder('e'); + //Use lowercase conversion to be case-insensitive + $qb->where($qb->expr()->like('LOWER(e.additional_names)', 'LOWER(:name)')); + $qb->setParameter('name', '%'.$name.'%'); + + $result = $qb->getQuery()->getResult(); + + if (count($result) === 1) { + return $result[0]; + }*/ + + //If we find nothing, return null + return null; + } + + /** + * Similar to findForInfoProvider, but will create a new element with the given name if none was found. + * @param string $name + * @return AbstractStructuralDBElement + */ + public function findOrCreateForInfoProvider(string $name): AbstractStructuralDBElement + { + $entity = $this->findForInfoProvider($name); + if (null === $entity) { + + //Try to find if we already have an element cached for this name + $entity = $this->getNewEntityFromCache($name, null); + if ($entity) { + return $entity; + } + + $class = $this->getClassName(); + /** @var AbstractStructuralDBElement $entity */ + $entity = new $class; + $entity->setName($name); + + $this->setNewEntityToCache($entity); + } + + return $entity; + } } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 749467e5..b11382d2 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -25,13 +25,16 @@ namespace App\Services\InfoProviderSystem; use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; +use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use Doctrine\ORM\EntityManagerInterface; /** * This class converts DTOs to entities which can be persisted in the DB @@ -39,6 +42,10 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; class DTOtoEntityConverter { + public function __construct(private readonly EntityManagerInterface $em) + { + } + public function convertParameter(ParameterDTO $dto, PartParameter $entity = new PartParameter()): PartParameter { $entity->setName($dto->name); @@ -79,6 +86,8 @@ class DTOtoEntityConverter $entity->setDescription($dto->description ?? ''); $entity->setComment($dto->notes ?? ''); + $entity->setManufacturer($this->getOrCreateEntity(Manufacturer::class, $dto->manufacturer)); + $entity->setManufacturerProductNumber($dto->mpn ?? ''); $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); @@ -95,4 +104,14 @@ class DTOtoEntityConverter return $entity; } + private function getOrCreateEntity(string $class, ?string $name): ?AbstractStructuralDBElement + { + //Fall through to make converting easier + if ($name === null) { + return null; + } + + return $this->em->getRepository($class)->findOrCreateForInfoProvider($name); + } + } \ No newline at end of file From c4439cc9db73bfe856ffb3cdc6beb93302909898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 12 Jul 2023 23:58:40 +0200 Subject: [PATCH 07/34] Mark newly created entities better in structural entity selector --- .../structural_entity_select_controller.js | 20 +++++++++++++++++-- .../Helper/StructuralEntityChoiceHelper.php | 3 +++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 93d26d01..05856a31 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -22,6 +22,8 @@ import '../../css/components/tom-select_extensions.css'; import TomSelect from "tom-select"; import {Controller} from "@hotwired/stimulus"; +import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js' + export default class extends Controller { _tomSelect; @@ -40,7 +42,7 @@ export default class extends Controller { allowEmptyOption: true, selectOnTab: true, maxOptions: null, - create: allowAdd, + create: allowAdd ? this.createItem.bind(this) : false, createFilter: /\D/, //Must contain a non-digit character, otherwise they would be recognized as DB ID searchField: [ @@ -68,6 +70,14 @@ export default class extends Controller { this._tomSelect.sync(); } + createItem(input, callback) { + callback({ + value: input, + text: input, + not_in_db_yet: true, + }); + } + updateValidity() { //Mark this input as invalid, if the selected option is disabled @@ -104,7 +114,13 @@ export default class extends Controller { if (data.parent) { name += escape(data.parent) + " → "; } - name += "" + escape(data.text) + ""; + + if (data.not_in_db_yet) { + //Not yet added items are shown italic and with a badge + name += "" + escape(data.text) + "" + "" + trans(ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB) + ""; + } else { + name += "" + escape(data.text) + ""; + } return '
' + (data.image ? "" : "") + name + '
'; } diff --git a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php index 13e1626e..79dae8a2 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php @@ -86,6 +86,9 @@ class StructuralEntityChoiceHelper $tmp += ['data-filetype_filter' => $choice->getFiletypeFilter()]; } + //Show entities that are not added to DB yet separately from other entities + $tmp['data-not_in_db_yet'] = $choice->getID() === null; + return $tmp; } From 0cb46039dd988082f418c297652fef1d767e3026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Fri, 14 Jul 2023 00:09:22 +0200 Subject: [PATCH 08/34] Allow to retrieve price and shopping informations from info providers --- .../structural_entity_select_controller.js | 9 +- src/Entity/PriceInformations/Currency.php | 3 +- .../Helper/StructuralEntityChoiceHelper.php | 3 + .../Helper/StructuralEntityChoiceLoader.php | 7 ++ src/Repository/CurrencyRepository.php | 59 ++++++++++++ .../StructuralDBElementRepository.php | 2 + .../InfoProviderSystem/DTOs/PartDetailDTO.php | 2 + .../InfoProviderSystem/DTOs/PriceDTO.php | 53 ++++++++++ .../DTOs/PurchaseInfoDTO.php | 44 +++++++++ .../DTOtoEntityConverter.php | 74 +++++++++++++- .../Providers/Element14Provider.php | 96 ++++++++++++++++++- 11 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/Repository/CurrencyRepository.php create mode 100644 src/Services/InfoProviderSystem/DTOs/PriceDTO.php create mode 100644 src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 05856a31..e775af8a 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -107,7 +107,14 @@ export default class extends Controller { } if (data.short) { - return '
' + escape(data.short) + '
'; + let short = escape(data.short) + + //Make text italic, if the item is not yet in the DB + if (data.not_in_db_yet) { + short = '' + short + ''; + } + + return '
' + short + '
'; } let name = ""; diff --git a/src/Entity/PriceInformations/Currency.php b/src/Entity/PriceInformations/Currency.php index 80fe6c5e..548e45f6 100644 --- a/src/Entity/PriceInformations/Currency.php +++ b/src/Entity/PriceInformations/Currency.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Entity\PriceInformations; +use App\Repository\CurrencyRepository; use Doctrine\DBAL\Types\Types; use App\Entity\Attachments\CurrencyAttachment; use App\Entity\Base\AbstractStructuralDBElement; @@ -42,7 +43,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @extends AbstractStructuralDBElement */ #[UniqueEntity('iso_code')] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CurrencyRepository::class)] #[ORM\Table(name: 'currencies')] #[ORM\Index(name: 'currency_idx_name', columns: ['name'])] #[ORM\Index(name: 'currency_idx_parent_name', columns: ['parent_id', 'name'])] diff --git a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php index 79dae8a2..402270ce 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php @@ -102,6 +102,9 @@ class StructuralEntityChoiceHelper $symbol = empty($choice->getIsoCode()) ? null : Currencies::getSymbol($choice->getIsoCode()); $tmp['data-short'] = $options['short'] ? $symbol : $choice->getName(); + //Show entities that are not added to DB yet separately from other entities + $tmp['data-not_in_db_yet'] = $choice->getID() === null; + return $tmp + [ 'data-symbol' => $symbol, ]; diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php index 97230319..5d466a73 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Form\Type\Helper; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\PriceInformations\Currency; use App\Repository\StructuralDBElementRepository; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; @@ -59,6 +60,12 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader public function createNewEntitiesFromValue(string $value): array { if (!$this->options['allow_add']) { + //Always allow the starting element to be added + if ($this->starting_element !== null && $this->starting_element->getID() === null) { + $this->entityManager->persist($this->starting_element); + return [$this->starting_element]; + } + throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!'); } diff --git a/src/Repository/CurrencyRepository.php b/src/Repository/CurrencyRepository.php new file mode 100644 index 00000000..47642f4b --- /dev/null +++ b/src/Repository/CurrencyRepository.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Repository; + +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\PriceInformations\Currency; +use Symfony\Component\Intl\Currencies; + +/** + * @extends StructuralDBElementRepository + */ +class CurrencyRepository extends StructuralDBElementRepository +{ + /** + * Finds or create a currency with the given ISO code. + * @param string $iso_code + * @return Currency + */ + public function findOrCreateByISOCode(string $iso_code): Currency + { + //Normalize ISO code + $iso_code = strtoupper($iso_code); + + //Try to find currency + $currency = $this->findOneBy(['iso_code' => $iso_code]); + if ($currency !== null) { + return $currency; + } + + //Create currency if it does not exist + $name = Currencies::getName($iso_code); + + $currency = $this->findOrCreateForInfoProvider($name); + $currency->setIsoCode($iso_code); + + return $currency; + } +} \ No newline at end of file diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index 48c10414..529cce79 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -192,6 +192,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository * Also, it will try to find the element using the additional names field, of the elements. * @param string $name * @return AbstractStructuralDBElement|null + * @phpstan-return TEntityClass|null */ public function findForInfoProvider(string $name): ?AbstractStructuralDBElement { @@ -228,6 +229,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository * Similar to findForInfoProvider, but will create a new element with the given name if none was found. * @param string $name * @return AbstractStructuralDBElement + * @phpstan-return TEntityClass */ public function findOrCreateForInfoProvider(string $name): AbstractStructuralDBElement { diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 13bb93d8..8854241d 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -44,6 +44,8 @@ class PartDetailDTO extends SearchResultDTO public readonly ?array $datasheets = null, /** @var ParameterDTO[]|null */ public readonly ?array $parameters = null, + /** @var PurchaseInfoDTO[]|null */ + public readonly ?array $vendor_infos = null, ) { parent::__construct( provider_key: $provider_key, diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php new file mode 100644 index 00000000..e10166ef --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use Brick\Math\BigDecimal; + +/** + * This DTO represents a price for a single unit in a certain discount range + */ +class PriceDTO +{ + private readonly BigDecimal $price_as_big_decimal; + + public function __construct( + /** @var float The minimum amount that needs to get ordered for this price to be valid */ + public readonly float $minimum_discount_amount, + /** @var string The price as string (with .) */ + public readonly string $price, + /** @var string The currency of the used ISO code of this price detail */ + public readonly ?string $currency_iso_code, + /** @var bool If the price includes tax */ + public readonly ?bool $includes_tax = true, + ) + { + $this->price_as_big_decimal = BigDecimal::of($this->price); + } + + public function getPriceAsBigDecimal(): BigDecimal + { + return $this->price_as_big_decimal; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php new file mode 100644 index 00000000..09bbfeb2 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class PurchaseInfoDTO +{ + public function __construct( + public readonly string $distributor_name, + public readonly string $order_number, + /** @var PriceDTO[] */ + public readonly array $prices, + /** @var string|null An url to the product page of the vendor */ + public readonly ?string $product_url = null, + ) + { + //Ensure that the prices are PriceDTO instances + foreach ($this->prices as $price) { + if (!$price instanceof PriceDTO) { + throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances'); + } + } + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index b11382d2..96323df4 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -31,9 +31,16 @@ use App\Entity\Parameters\PartParameter; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use Brick\Math\BigDecimal; use Doctrine\ORM\EntityManagerInterface; /** @@ -42,7 +49,7 @@ use Doctrine\ORM\EntityManagerInterface; class DTOtoEntityConverter { - public function __construct(private readonly EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency) { } @@ -60,6 +67,35 @@ class DTOtoEntityConverter return $entity; } + public function convertPrice(PriceDTO $dto, Pricedetail $entity = new Pricedetail()): Pricedetail + { + $entity->setMinDiscountQuantity($dto->minimum_discount_amount); + $entity->setPrice($dto->getPriceAsBigDecimal()); + + //Currency TODO + if ($dto->currency_iso_code !== null) { + $entity->setCurrency($this->getCurrency($dto->currency_iso_code)); + } else { + $entity->setCurrency(null); + } + + + return $entity; + } + + public function convertPurchaseInfo(PurchaseInfoDTO $dto, Orderdetail $entity = new Orderdetail()): Orderdetail + { + $entity->setSupplierpartnr($dto->order_number); + $entity->setSupplierProductUrl($dto->product_url ?? ''); + + $entity->setSupplier($this->getOrCreateEntityNonNull(Supplier::class, $dto->distributor_name)); + foreach ($dto->prices as $price) { + $entity->addPricedetail($this->convertPrice($price)); + } + + return $entity; + } + public function convertFile(FileDTO $dto, PartAttachment $entity = new PartAttachment()): PartAttachment { $entity->setURL($dto->url); @@ -101,9 +137,22 @@ class DTOtoEntityConverter $entity->addAttachment($this->convertFile($datasheet)); } + //Add orderdetails and prices + foreach ($dto->vendor_infos ?? [] as $vendor_info) { + $entity->addOrderdetail($this->convertPurchaseInfo($vendor_info)); + } + return $entity; } + /** + * @template T of AbstractStructuralDBElement + * @param string $class + * @phpstan-param class-string $class + * @param string|null $name + * @return AbstractStructuralDBElement|null + * @phpstan-return T|null + */ private function getOrCreateEntity(string $class, ?string $name): ?AbstractStructuralDBElement { //Fall through to make converting easier @@ -111,7 +160,30 @@ class DTOtoEntityConverter return null; } + return $this->getOrCreateEntityNonNull($class, $name); + } + + /** + * @template T of AbstractStructuralDBElement + * @param string $class The class of the entity to create + * @phpstan-param class-string $class + * @param string $name The name of the entity to create + * @return AbstractStructuralDBElement + * @phpstan-return T|null + */ + private function getOrCreateEntityNonNull(string $class, string $name): AbstractStructuralDBElement + { return $this->em->getRepository($class)->findOrCreateForInfoProvider($name); } + private function getCurrency(string $iso_code): ?Currency + { + //Check if the currency is the base currency (then we can just return null) + if ($iso_code === $this->base_currency) { + return null; + } + + return $this->em->getRepository(Currency::class)->findOrCreateByISOCode($iso_code); + } + } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 854b4a1b..b1954c51 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -28,7 +28,9 @@ use App\Form\InfoProviderSystem\ProviderSelectType; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; class Element14Provider implements InfoProviderInterface @@ -39,6 +41,8 @@ class Element14Provider implements InfoProviderInterface private const API_VERSION_NUMBER = '1.2'; private const NUMBER_OF_RESULTS = 20; + public const DISTRIBUTOR_NAME = 'Farnell'; + private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; @@ -105,15 +109,21 @@ class Element14Provider implements InfoProviderInterface mpn: $product['translatedManufacturerPartNumber'], preview_image_url: $this->toImageUrl($product['image'] ?? null), manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), - provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'], + provider_url: $this->generateProductURL($product['sku']), datasheets: $this->parseDataSheets($product['datasheets'] ?? null), parameters: $this->attributesToParameters($product['attributes'] ?? null), + vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? []) ); } return $result; } + private function generateProductURL($sku): string + { + return 'https://' . self::FARNELL_STORE_ID . '/' . $sku; + } + /** * @param mixed[]|null $datasheets * @return FileDTO[]|null Array of FileDTOs @@ -147,6 +157,90 @@ class Element14Provider implements InfoProviderInterface return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; } + /** + * Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO + * @param string $sku + * @param array $prices + * @return array + */ + private function pricesToVendorInfo(string $sku, array $prices): array + { + $price_dtos = []; + + foreach ($prices as $price) { + $price_dtos[] = new PriceDTO( + minimum_discount_amount: $price['from'], + price: (string) $price['cost'], + currency_iso_code: $this->getUsedCurrency(), + includes_tax: false, + ); + } + + return [ + new PurchaseInfoDTO( + distributor_name: self::DISTRIBUTOR_NAME, + order_number: $sku, + prices: $price_dtos, + product_url: $this->generateProductURL($sku) + ) + ]; + } + + public function getUsedCurrency(): string + { + //Decide based on the shop ID + return match (self::FARNELL_STORE_ID) { + 'bg.farnell.com' => 'EUR', + 'cz.farnell.com' => 'CZK', + 'dk.farnell.com' => 'DKK', + 'at.farnell.com' => 'EUR', + 'ch.farnell.com' => 'CHF', + 'de.farnell.com' => 'EUR', + 'cpc.farnell.com' => 'GBP', + 'cpcireland.farnell.com' => 'EUR', + 'export.farnell.com' => 'GBP', + 'onecall.farnell.com' => 'GBP', + 'ie.farnell.com' => 'EUR', + 'il.farnell.com' => 'USD', + 'uk.farnell.com' => 'GBP', + 'es.farnell.com' => 'EUR', + 'ee.farnell.com' => 'EUR', + 'fi.farnell.com' => 'EUR', + 'fr.farnell.com' => 'EUR', + 'hu.farnell.com' => 'HUF', + 'it.farnell.com' => 'EUR', + 'lt.farnell.com' => 'EUR', + 'lv.farnell.com' => 'EUR', + 'be.farnell.com' => 'EUR', + 'nl.farnell.com' => 'EUR', + 'no.farnell.com' => 'NOK', + 'pl.farnell.com' => 'PLN', + 'pt.farnell.com' => 'EUR', + 'ro.farnell.com' => 'EUR', + 'ru.farnell.com' => 'RUB', + 'sk.farnell.com' => 'EUR', + 'si.farnell.com' => 'EUR', + 'se.farnell.com' => 'SEK', + 'tr.farnell.com' => 'TRY', + 'canada.newark.com' => 'CAD', + 'mexico.newark.com' => 'MXN', + 'www.newark.com' => 'USD', + 'cn.element14.com' => 'CNY', + 'au.element14.com' => 'AUD', + 'nz.element14.com' => 'NZD', + 'hk.element14.com' => 'HKD', + 'sg.element14.com' => 'SGD', + 'my.element14.com' => 'MYR', + 'ph.element14.com' => 'PHP', + 'th.element14.com' => 'THB', + 'in.element14.com' => 'INR', + 'tw.element14.com' => 'TWD', + 'kr.element14.com' => 'KRW', + 'vn.element14.com' => 'VND', + default => throw new \RuntimeException('Unknown store ID: ' . self::FARNELL_STORE_ID) + }; + } + /** * @param array|null $attributes * @return ParameterDTO[]|null From f9fdae9de98d8f9fee56122a707352882b7c6cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 01:01:20 +0200 Subject: [PATCH 09/34] Added an TME data provider --- config/services.yaml | 5 + .../InfoProviderSystem/DTOs/ParameterDTO.php | 17 +- .../InfoProviderSystem/DTOs/PartDetailDTO.php | 4 + .../DTOs/SearchResultDTO.php | 2 + .../DTOtoEntityConverter.php | 2 + .../Providers/TMEClient.php | 92 ++++++ .../Providers/TMEProvider.php | 276 ++++++++++++++++++ 7 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/Services/InfoProviderSystem/Providers/TMEClient.php create mode 100644 src/Services/InfoProviderSystem/Providers/TMEProvider.php diff --git a/config/services.yaml b/config/services.yaml index cec5019d..54807566 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -248,6 +248,11 @@ services: arguments: $api_key: '%env(PROVIDER_ELEMENT14_KEY)%' + App\Services\InfoProviderSystem\Providers\TMEClient: + arguments: + $secret: '%env(PROVIDER_TME_SECRET)%' + $token: '%env(PROVIDER_TME_KEY)%' + #################################################################################################################### # Symfony overrides #################################################################################################################### diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index 9613a859..4c82f1db 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -41,7 +41,22 @@ class ParameterDTO public static function parseValueField(string $name, string|float $value, ?string $unit = null, ?string $symbol = null, ?string $group = null): self { if (is_float($value) || is_numeric($value)) { - return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol); + return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol, group: $group); + } + + return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); + } + + public static function parseValueIncludingUnit(string $name, string|float $value, ?string $symbol = null, ?string $group = null): self + { + if (is_float($value) || is_numeric($value)) { + return new self($name, value_typ: (float) $value, symbol: $symbol, group: $group); + } + + $unit = null; + if (preg_match('/^(?[0-9.]+)\s*(?[a-zA-Z]+)$/', $value, $matches)) { + $value = $matches['value']; + $unit = $matches['unit']; } return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 8854241d..f24e220f 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -33,6 +33,7 @@ class PartDetailDTO extends SearchResultDTO string $provider_id, string $name, string $description, + ?string $category = null, ?string $manufacturer = null, ?string $mpn = null, ?string $preview_image_url = null, @@ -46,12 +47,15 @@ class PartDetailDTO extends SearchResultDTO public readonly ?array $parameters = null, /** @var PurchaseInfoDTO[]|null */ public readonly ?array $vendor_infos = null, + /** The mass of the product in grams */ + public readonly ?float $mass = null, ) { parent::__construct( provider_key: $provider_key, provider_id: $provider_id, name: $name, description: $description, + category: $category, manufacturer: $manufacturer, mpn: $mpn, preview_image_url: $preview_image_url, diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 3687dca9..228944ab 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -36,6 +36,8 @@ class SearchResultDTO public readonly string $name, /** @var string A short description of the part */ public readonly string $description, + /** @var string|null The category the distributor assumes for the part */ + public readonly ?string $category = null, /** @var string|null The manufacturer of the part */ public readonly ?string $manufacturer = null, /** @var string|null The manufacturer part number */ diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 96323df4..36a449e9 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -122,6 +122,8 @@ class DTOtoEntityConverter $entity->setDescription($dto->description ?? ''); $entity->setComment($dto->notes ?? ''); + $entity->setMass($dto->mass); + $entity->setManufacturer($this->getOrCreateEntity(Manufacturer::class, $dto->manufacturer)); $entity->setManufacturerProductNumber($dto->mpn ?? ''); diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php new file mode 100644 index 00000000..8c2a4430 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php @@ -0,0 +1,92 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use Symfony\Component\HttpClient\DecoratorTrait; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +class TMEClient +{ + public const BASE_URI = 'https://api.tme.eu'; + + public function __construct(private readonly HttpClientInterface $tmeClient, private readonly string $token, private readonly string $secret) + { + + } + + public function makeRequest(string $action, array $parameters): ResponseInterface + { + $parameters['Token'] = $this->token; + $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->secret); + + return $this->tmeClient->request('POST', $this->getUrlForAction($action), [ + 'body' => $parameters, + ]); + } + + public function isUsable(): bool + { + if ($this->token === '' || $this->secret === '') { + return false; + } + + return true; + } + + + /** + * Generates the signature for the given action and parameters. + * Taken from https://github.com/tme-dev/TME-API/blob/master/PHP/basic/using_curl.php + */ + public function getSignature(string $action, array $parameters, string $appSecret): string + { + $parameters = $this->sortSignatureParams($parameters); + + $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + $signatureBase = strtoupper('POST') . + '&' . rawurlencode($this->getUrlForAction($action)) . '&' . rawurlencode($queryString); + + return base64_encode(hash_hmac('sha1', $signatureBase, $appSecret, true)); + } + + private function getUrlForAction(string $action): string + { + return self::BASE_URI . '/' . $action . '.json'; + } + + private function sortSignatureParams(array $params): array + { + ksort($params); + + foreach ($params as &$value) { + if (is_array($value)) { + $value = $this->sortSignatureParams($value); + } + } + + return $params; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php new file mode 100644 index 00000000..506e87f2 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -0,0 +1,276 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class TMEProvider implements InfoProviderInterface +{ + + private const VENDOR_NAME = 'TME'; + + private string $country = 'DE'; + private string $language = 'en'; + private string $currency = 'EUR'; + /** + * @var bool If true, the prices are gross prices. If false, the prices are net prices. + */ + private bool $get_gross_prices = true; + + public function __construct(private readonly TMEClient $tmeClient) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'TME', + 'description' => 'This provider uses the API of TME (Transfer Multipart).', + 'url' => 'https://tme.eu/', + ]; + } + + public function getProviderKey(): string + { + return 'tme'; + } + + public function isActive(): bool + { + return $this->tmeClient->isUsable(); + } + + public function searchByKeyword(string $keyword): array + { + $response = $this->tmeClient->makeRequest('Products/Search', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SearchPlain' => $keyword, + ]); + + $data = $response->toArray()['Data']; + + $result = []; + + foreach($data['ProductList'] as $product) { + $result[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['Symbol'], + name: $product['OriginalSymbol'] ?? $product['Symbol'], + description: $product['Description'], + category: $product['Category'], + manufacturer: $product['Producer'], + mpn: $product['OriginalSymbol'] ?? null, + preview_image_url: $this->normalizeURL($product['Photo']), + manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']), + provider_url: $this->normalizeURL($product['ProductInformationPage']), + ); + } + + return $result; + } + + public function getDetails(string $id): PartDetailDTO + { + $response = $this->tmeClient->makeRequest('Products/GetProducts', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $product = $response->toArray()['Data']['ProductList'][0]; + + //Add a explicit https:// to the url if it is missing + $productInfoPage = $this->normalizeURL($product['ProductInformationPage']); + + $files = $this->getFiles($id); + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['Symbol'], + name: $product['OriginalSymbol'] ?? $product['Symbol'], + description: $product['Description'], + category: $product['Category'], + manufacturer: $product['Producer'], + mpn: $product['OriginalSymbol'] ?? null, + preview_image_url: $this->normalizeURL($product['Photo']), + manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']), + provider_url: $productInfoPage, + datasheets: $files['datasheets'], + parameters: $this->getParameters($id), + vendor_infos: [$this->getVendorInfo($id, $productInfoPage)], + mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null, + ); + } + + /** + * Fetches all files for a given product id + * @param string $id + * @return array> An array with the keys 'datasheet' + * @phpstan-return array{datasheets: list} + */ + public function getFiles(string $id): array + { + $response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']; + $files = $data['ProductList'][0]['Files']; + + //Extract datasheets + $documentList = $files['DocumentList']; + $datasheets = []; + foreach($documentList as $document) { + $datasheets[] = new FileDTO( + url: $this->normalizeURL($document['DocumentUrl']), + ); + } + + + return [ + 'datasheets' => $datasheets, + ]; + } + + /** + * Fetches the vendor/purchase information for a given product id. + * @param string $id + * @param string|null $productURL + * @return PurchaseInfoDTO + */ + public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO + { + $response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'Currency' => $this->currency, + 'GrossPrices' => $this->get_gross_prices, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']; + $currency = $data['Currency']; + $include_tax = $data['PriceType'] === 'GROSS'; + + + $product = $response->toArray()['Data']['ProductList'][0]; + $vendor_order_number = $product['Symbol']; + $priceList = $product['PriceList']; + + $prices = []; + foreach ($priceList as $price) { + $prices[] = new PriceDTO( + minimum_discount_amount: $price['Amount'], + price: (string) $price['PriceValue'], + currency_iso_code: $currency, + includes_tax: $include_tax, + ); + } + + return new PurchaseInfoDTO( + distributor_name: self::VENDOR_NAME, + order_number: $vendor_order_number, + prices: $prices, + product_url: $productURL, + ); + } + + /** + * Fetches the parameters of a product + * @param string $id + * @return ParameterDTO[] + */ + public function getParameters(string $id): array + { + $response = $this->tmeClient->makeRequest('Products/GetParameters', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']['ProductList'][0]; + + $result = []; + + foreach($data['ParameterList'] as $parameter) { + $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']); + } + + return $result; + } + + /** + * Convert the array of product statuses to a single manufacturing status + * @param array $statusArray + * @return ManufacturingStatus + */ + private function productStatusArrayToManufacturingStatus(array $statusArray): ManufacturingStatus + { + if (in_array('AVAILABLE_WHILE_STOCKS_LAST', $statusArray, true)) { + return ManufacturingStatus::EOL; + } + + if (in_array('INVALID', $statusArray, true)) { + return ManufacturingStatus::DISCONTINUED; + } + + //By default we assume that the part is active + return ManufacturingStatus::ACTIVE; + } + + + + private function normalizeURL(string $url): string + { + //If a URL starts with // we assume that it is a relative URL and we add the protocol + if (str_starts_with($url, '//')) { + return 'https:' . $url; + } + + return $url; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } +} \ No newline at end of file From 94a26ae75a1352c1f7a730c40f7c73a0c7582a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 01:41:29 +0200 Subject: [PATCH 10/34] Allow to extract ranges from paramaters --- .../InfoProviderSystem/DTOs/ParameterDTO.php | 44 ++++- .../DTOs/ParameterDTOTest.php | 164 ++++++++++++++++++ 2 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 tests/Services/InfoProviderSystem/DTOs/ParameterDTOTest.php diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index 4c82f1db..6500164b 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -38,27 +38,59 @@ class ParameterDTO } + /** + * This function tries to decide on the value, if it is a numerical value (which is then stored in one of the value_*) fields) or a text value (which is stored in value_text). + * It is possible to give ranges like 1...2 here, which will be parsed as value_min: 1.0, value_max: 2.0. + * @param string $name + * @param string|float $value + * @param string|null $unit + * @param string|null $symbol + * @param string|null $group + * @return self + */ public static function parseValueField(string $name, string|float $value, ?string $unit = null, ?string $symbol = null, ?string $group = null): self { if (is_float($value) || is_numeric($value)) { return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol, group: $group); } + //Try to parse as range + if (str_contains($value, '...')) { + $parts = explode('...', $value); + if (count($parts) === 2) { + + //Ensure that both parts are numerical + if (is_numeric($parts[0]) && is_numeric($parts[1])) { + return new self($name, value_min: (float) $parts[0], value_max: (float) $parts[1], unit: $unit, symbol: $symbol, group: $group); + } + } + } + return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); } + /** + * This function tries to decide on the value, if it is a numerical value (which is then stored in one of the value_*) fields) or a text value (which is stored in value_text). + * It also tries to extract the unit from the value field (so 3kg will be parsed as value_typ: 3.0, unit: kg). + * Ranges like 1...2 will be parsed as value_min: 1.0, value_max: 2.0. + * @param string $name + * @param string|float $value + * @param string|null $symbol + * @param string|null $group + * @return self + */ public static function parseValueIncludingUnit(string $name, string|float $value, ?string $symbol = null, ?string $group = null): self { - if (is_float($value) || is_numeric($value)) { - return new self($name, value_typ: (float) $value, symbol: $symbol, group: $group); - } - + //Try to extract unit from value $unit = null; - if (preg_match('/^(?[0-9.]+)\s*(?[a-zA-Z]+)$/', $value, $matches)) { + if (is_string($value) && preg_match('/^(?[0-9.]+)\s*(?[°a-zA-Z_]+\s?\w{0,4})$/u', $value, $matches)) { $value = $matches['value']; $unit = $matches['unit']; + + return self::parseValueField(name: $name, value: $value, unit: $unit, symbol: $symbol, group: $group); } - return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); + //Otherwise we assume that no unit is given + return self::parseValueField(name: $name, value: $value, unit: null, symbol: $symbol, group: $group); } } \ No newline at end of file diff --git a/tests/Services/InfoProviderSystem/DTOs/ParameterDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/ParameterDTOTest.php new file mode 100644 index 00000000..26a4483a --- /dev/null +++ b/tests/Services/InfoProviderSystem/DTOs/ParameterDTOTest.php @@ -0,0 +1,164 @@ +. + */ + +namespace App\Tests\Services\InfoProviderSystem\DTOs; + +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use PHPUnit\Framework\TestCase; + +class ParameterDTOTest extends TestCase +{ + + public function parseValueFieldDataProvider(): \Generator + { + //Text value + yield [ + new ParameterDTO('test', value_text: 'test', unit: 'm', symbol: 'm', group: 'test'), + 'test', + 'test', + 'm', + 'm', + 'test' + ]; + + //Numerical value + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: 'm', symbol: 'm', group: 'test'), + 'test', + 1.0, + 'm', + 'm', + 'test' + ]; + + //Numerical value with unit should be parsed as text value + yield [ + new ParameterDTO('test', value_text: '1.0 m', unit: 'm', symbol: 'm', group: 'test'), + 'test', + '1.0 m', + 'm', + 'm', + 'test' + ]; + + //Test ranges + yield [ + new ParameterDTO('test', value_min: 1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'), + 'test', + '1.0...2.0', + 'kg', + 'm', + 'test' + ]; + } + + public function parseValueIncludingUnitDataProvider(): \Generator + { + //Text value + yield [ + new ParameterDTO('test', value_text: 'test', unit: null, symbol: 'm', group: 'test'), + 'test', + 'test', + 'm', + 'test' + ]; + + //Numerical value + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: null, symbol: 'm', group: 'test'), + 'test', + 1.0, + 'm', + 'test' + ]; + + //Numerical value with unit should extract unit correctly + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: 'kg', symbol: 'm', group: 'test'), + 'test', + '1.0 kg', + 'm', + 'test' + ]; + + //Should work without space between value and unit + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: 'kg', symbol: 'm', group: 'test'), + 'test', + '1.0kg', + 'm', + 'test' + ]; + + //Allow ° as unit symbol + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: '°C', symbol: 'm', group: 'test'), + 'test', + '1.0°C', + 'm', + 'test' + ]; + + //Allow _ in units + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: 'C_m', symbol: 'm', group: 'test'), + 'test', + '1.0C_m', + 'm', + 'test' + ]; + + //Allow a single space in units + yield [ + new ParameterDTO('test', value_typ: 1.0, unit: 'C m', symbol: 'm', group: 'test'), + 'test', + '1.0C m', + 'm', + 'test' + ]; + + //Test ranges + yield [ + new ParameterDTO('test', value_min: 1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'), + 'test', + '1.0...2.0 kg', + 'm', + 'test' + ]; + } + + /** + * @dataProvider parseValueFieldDataProvider + * @return void + */ + public function testParseValueField(ParameterDTO $expected, string $name, string|float $value, ?string $unit = null, ?string $symbol = null, ?string $group = null) + { + $this->assertEquals($expected, ParameterDTO::parseValueField($name, $value, $unit, $symbol, $group)); + } + + /** + * @dataProvider parseValueIncludingUnitDataProvider + * @return void + */ + public function testParseValueIncludingUnit(ParameterDTO $expected, string $name, string|float $value, ?string $symbol = null, ?string $group = null) + { + $this->assertEquals($expected, ParameterDTO::parseValueIncludingUnit($name, $value, $symbol, $group)); + } +} From de82249d8dcc34ce408913620e22d7b346043284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 01:52:46 +0200 Subject: [PATCH 11/34] Provide footprint information on TMEProvider --- .../InfoProviderSystem/DTOtoEntityConverter.php | 2 ++ .../Providers/TMEProvider.php | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 36a449e9..6456bb99 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -28,6 +28,7 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; @@ -125,6 +126,7 @@ class DTOtoEntityConverter $entity->setMass($dto->mass); $entity->setManufacturer($this->getOrCreateEntity(Manufacturer::class, $dto->manufacturer)); + $entity->setFootprint($this->getOrCreateEntity(Footprint::class, $dto->footprint)); $entity->setManufacturerProductNumber($dto->mpn ?? ''); $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 506e87f2..0662175c 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -115,6 +115,10 @@ class TMEProvider implements InfoProviderInterface $files = $this->getFiles($id); + $footprint = null; + + $parameters = $this->getParameters($id, $footprint); + return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $product['Symbol'], @@ -126,8 +130,9 @@ class TMEProvider implements InfoProviderInterface preview_image_url: $this->normalizeURL($product['Photo']), manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']), provider_url: $productInfoPage, + footprint: $footprint, datasheets: $files['datasheets'], - parameters: $this->getParameters($id), + parameters: $parameters, vendor_infos: [$this->getVendorInfo($id, $productInfoPage)], mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null, ); @@ -211,9 +216,10 @@ class TMEProvider implements InfoProviderInterface /** * Fetches the parameters of a product * @param string $id + * @param string|null $footprint_name You can pass a variable by reference, where the name of the footprint will be stored * @return ParameterDTO[] */ - public function getParameters(string $id): array + public function getParameters(string $id, string|null &$footprint_name = null): array { $response = $this->tmeClient->makeRequest('Products/GetParameters', [ 'Country' => $this->country, @@ -225,8 +231,15 @@ class TMEProvider implements InfoProviderInterface $result = []; + $footprint_name = null; + foreach($data['ParameterList'] as $parameter) { $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']); + + //Check if the parameter is the case/footprint + if ($parameter['ParameterId'] === 35) { + $footprint_name = $parameter['ParameterValue']; + } } return $result; From 8ea92ef330ef87c43c9e76b9e36a2870a08d98c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 18:18:35 +0200 Subject: [PATCH 12/34] Added tests for DTOConverter --- .../InfoProviderSystem/DTOs/FileDTO.php | 12 +- .../InfoProviderSystem/DTOs/ParameterDTO.php | 4 + .../InfoProviderSystem/DTOs/PartDetailDTO.php | 6 +- .../InfoProviderSystem/DTOs/PriceDTO.php | 4 + .../DTOs/PurchaseInfoDTO.php | 3 + .../DTOs/SearchResultDTO.php | 3 + .../DTOtoEntityConverter.php | 75 +++++++- .../InfoProviderSystem/ProviderRegistry.php | 10 +- .../DTOs/PurchaseInfoDTOTest.php | 34 ++++ .../DTOtoEntityConverterTest.php | 181 ++++++++++++++++++ .../ProviderRegistryTest.php | 108 +++++++++++ 11 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php create mode 100644 tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php create mode 100644 tests/Services/InfoProviderSystem/ProviderRegistryTest.php diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php index 7c005b9b..516ab949 100644 --- a/src/Services/InfoProviderSystem/DTOs/FileDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -23,12 +23,20 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +/** + * This DTO represents a file that can be downloaded from a URL. + * This could be a datasheet, a 3D model, a picture or similar. + */ class FileDTO { + /** + * @param string $url The URL where to get this file + * @param string|null $name Optionally the name of this file + */ public function __construct( - /** The URL where to get this file */ public readonly string $url, - /** Optionally the name of this file */ public readonly ?string $name = null, ) {} + + } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index 6500164b..f2a0d978 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -23,6 +23,10 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +/** + * This DTO represents a parameter of a part (similar to the AbstractParameter entity). + * This could be a voltage, a current, a temperature or similar. + */ class ParameterDTO { public function __construct( diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index f24e220f..7a7a83ca 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -24,8 +24,10 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; use App\Entity\Parts\ManufacturingStatus; -use Hoa\Zformat\Parameter; +/** + * This DTO represents a part with all its details. + */ class PartDetailDTO extends SearchResultDTO { public function __construct( @@ -43,6 +45,8 @@ class PartDetailDTO extends SearchResultDTO public readonly ?string $notes = null, /** @var FileDTO[]|null */ public readonly ?array $datasheets = null, + /** @var FileDTO[]|null */ + public readonly ?array $images = null, /** @var ParameterDTO[]|null */ public readonly ?array $parameters = null, /** @var PurchaseInfoDTO[]|null */ diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index e10166ef..8c563149 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -46,6 +46,10 @@ class PriceDTO $this->price_as_big_decimal = BigDecimal::of($this->price); } + /** + * Gets the price as BigDecimal + * @return BigDecimal + */ public function getPriceAsBigDecimal(): BigDecimal { return $this->price_as_big_decimal; diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php index 09bbfeb2..6073cc5f 100644 --- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +/** + * This DTO represents a purchase information for a part (supplier name, order number and prices). + */ class PurchaseInfoDTO { public function __construct( diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 228944ab..355041bf 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -25,6 +25,9 @@ namespace App\Services\InfoProviderSystem\DTOs; use App\Entity\Parts\ManufacturingStatus; +/** + * This DTO represents a search result for a part. + */ class SearchResultDTO { public function __construct( diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 6456bb99..b0e10d6a 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -47,13 +47,21 @@ use Doctrine\ORM\EntityManagerInterface; /** * This class converts DTOs to entities which can be persisted in the DB */ -class DTOtoEntityConverter +final class DTOtoEntityConverter { + private const TYPE_DATASHEETS_NAME = 'Datasheet'; + private const TYPE_IMAGE_NAME = 'Image'; public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency) { } + /** + * Converts the given DTO to a PartParameter entity. + * @param ParameterDTO $dto + * @param PartParameter $entity The entity to apply the DTO on. If null a new entity will be created + * @return PartParameter + */ public function convertParameter(ParameterDTO $dto, PartParameter $entity = new PartParameter()): PartParameter { $entity->setName($dto->name); @@ -68,6 +76,12 @@ class DTOtoEntityConverter return $entity; } + /** + * Converts the given DTO to a Pricedetail entity. + * @param PriceDTO $dto + * @param Pricedetail $entity + * @return Pricedetail + */ public function convertPrice(PriceDTO $dto, Pricedetail $entity = new Pricedetail()): Pricedetail { $entity->setMinDiscountQuantity($dto->minimum_discount_amount); @@ -84,6 +98,9 @@ class DTOtoEntityConverter return $entity; } + /** + * Converts the given DTO to an orderdetail entity. + */ public function convertPurchaseInfo(PurchaseInfoDTO $dto, Orderdetail $entity = new Orderdetail()): Orderdetail { $entity->setSupplierpartnr($dto->order_number); @@ -97,10 +114,19 @@ class DTOtoEntityConverter return $entity; } - public function convertFile(FileDTO $dto, PartAttachment $entity = new PartAttachment()): PartAttachment + /** + * Converts the given DTO to an Attachment entity. + * @param FileDTO $dto + * @param AttachmentType $type The type which should be used for the attachment + * @param PartAttachment $entity + * @return PartAttachment + */ + public function convertFile(FileDTO $dto, AttachmentType $type, PartAttachment $entity = new PartAttachment()): PartAttachment { $entity->setURL($dto->url); + $entity->setAttachmentType($type); + //If no name is given, try to extract the name from the URL if (empty($dto->name)) { $entity->setName(basename($dto->url)); @@ -137,8 +163,9 @@ class DTOtoEntityConverter } //Add datasheets + $datasheet_type = $this->getDatasheetType(); foreach ($dto->datasheets ?? [] as $datasheet) { - $entity->addAttachment($this->convertFile($datasheet)); + $entity->addAttachment($this->convertFile($datasheet, $datasheet_type)); } //Add orderdetails and prices @@ -150,6 +177,8 @@ class DTOtoEntityConverter } /** + * Get the existing entity of the given class with the given name or create it if it does not exist. + * If the name is null, null is returned. * @template T of AbstractStructuralDBElement * @param string $class * @phpstan-param class-string $class @@ -168,6 +197,7 @@ class DTOtoEntityConverter } /** + * Get the existing entity of the given class with the given name or create it if it does not exist. * @template T of AbstractStructuralDBElement * @param string $class The class of the entity to create * @phpstan-param class-string $class @@ -180,6 +210,11 @@ class DTOtoEntityConverter return $this->em->getRepository($class)->findOrCreateForInfoProvider($name); } + /** + * Returns the currency entity for the given ISO code or create it if it does not exist + * @param string $iso_code + * @return Currency|null + */ private function getCurrency(string $iso_code): ?Currency { //Check if the currency is the base currency (then we can just return null) @@ -190,4 +225,38 @@ class DTOtoEntityConverter return $this->em->getRepository(Currency::class)->findOrCreateByISOCode($iso_code); } + /** + * Returns the attachment type used for datasheets or creates it if it does not exist + * @return AttachmentType + */ + private function getDatasheetType(): AttachmentType + { + /** @var AttachmentType $tmp */ + $tmp = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::TYPE_DATASHEETS_NAME); + + //If the entity was newly created, set the file filter + if ($tmp->getId() === null) { + $tmp->setFiletypeFilter('application/pdf'); + } + + return $tmp; + } + + /** + * Returns the attachment type used for datasheets or creates it if it does not exist + * @return AttachmentType + */ + private function getImageType(): AttachmentType + { + /** @var AttachmentType $tmp */ + $tmp = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::TYPE_IMAGE_NAME); + + //If the entity was newly created, set the file filter + if ($tmp->getId() === null) { + $tmp->setFiletypeFilter('image/*'); + } + + return $tmp; + } + } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php index 2b9ef43b..921430e0 100644 --- a/src/Services/InfoProviderSystem/ProviderRegistry.php +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -25,24 +25,24 @@ namespace App\Services\InfoProviderSystem; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; -class ProviderRegistry +/** + * This class keeps track of all registered info providers and allows to find them by their key + */ +final class ProviderRegistry { - /** * @var InfoProviderInterface[] The info providers index by their keys - * @psalm-var array + * @phpstan-var array */ private array $providers_by_name = []; /** * @var InfoProviderInterface[] The enabled providers indexed by their keys - * @psalm-var array */ private array $providers_active = []; /** * @var InfoProviderInterface[] The disabled providers indexed by their keys - * @psalm-var array */ private array $providers_disabled = []; diff --git a/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php new file mode 100644 index 00000000..0442a873 --- /dev/null +++ b/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php @@ -0,0 +1,34 @@ +. + */ + +namespace App\Tests\Services\InfoProviderSystem\DTOs; + +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use PHPUnit\Framework\TestCase; + +class PurchaseInfoDTOTest extends TestCase +{ + public function testThrowOnInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The prices array must only contain PriceDTO instances'); + new PurchaseInfoDTO('test', 'test', [new \stdClass()]); + } +} diff --git a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php new file mode 100644 index 00000000..d9544145 --- /dev/null +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -0,0 +1,181 @@ +. + */ + +namespace App\Tests\Services\InfoProviderSystem; + +use App\Entity\Attachments\AttachmentType; +use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\DTOtoEntityConverter; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +class DTOtoEntityConverterTest extends WebTestCase +{ + + private ?DTOtoEntityConverter $service = null; + + public function setUp(): void + { + self::bootKernel(); + $this->service = self::getContainer()->get(DTOtoEntityConverter::class); + } + + public function testConvertParameter(): void + { + $dto = new ParameterDTO( + name: 'TestParameter', + value_text: 'Text', + value_typ: 10.0, value_min: 0.0, value_max: 100.0, + unit: 'kg', symbol: 'TP', group: 'TestGroup' + ); + + $entity = $this->service->convertParameter($dto); + + $this->assertEquals($dto->name, $entity->getName()); + $this->assertEquals($dto->value_text, $entity->getValueText()); + $this->assertEquals($dto->value_typ, $entity->getValueTypical()); + $this->assertEquals($dto->value_min, $entity->getValueMin()); + $this->assertEquals($dto->value_max, $entity->getValueMax()); + $this->assertEquals($dto->unit, $entity->getUnit()); + $this->assertEquals($dto->symbol, $entity->getSymbol()); + $this->assertEquals($dto->group, $entity->getGroup()); + } + + public function testConvertPriceOtherCurrency(): void + { + $dto = new PriceDTO( + minimum_discount_amount: 5, + price: "10.0", + currency_iso_code: 'CNY', + includes_tax: true, + ); + + $entity = $this->service->convertPrice($dto); + $this->assertEquals($dto->minimum_discount_amount, $entity->getMinDiscountQuantity()); + $this->assertEquals((float) $dto->price, (float) (string) $entity->getPrice()); + + //For non-base currencies, a new currency entity is created + $currency = $entity->getCurrency(); + $this->assertEquals($dto->currency_iso_code, $currency->getIsoCode()); + } + + public function testConvertPriceBaseCurrency(): void + { + $dto = new PriceDTO( + minimum_discount_amount: 5, + price: "10.0", + currency_iso_code: 'EUR', + includes_tax: true, + ); + + $entity = $this->service->convertPrice($dto); + + //For base currencies, the currency field is null + $this->assertNull($entity->getCurrency()); + } + + public function testConvertPurchaseInfo(): void + { + $prices = [ + new PriceDTO(1, "10.0", 'EUR'), + new PriceDTO(5, "9.0", 'EUR'), + ]; + + $dto = new PurchaseInfoDTO( + distributor_name: 'TestDistributor', + order_number: 'TestOrderNumber', + prices: $prices, + product_url: 'https://example.com', + ); + + $entity = $this->service->convertPurchaseInfo($dto); + + $this->assertEquals($dto->distributor_name, $entity->getSupplier()->getName()); + $this->assertEquals($dto->order_number, $entity->getSupplierPartNr()); + $this->assertEquals($dto->product_url, $entity->getSupplierProductUrl()); + } + + public function testConvertFileWithName(): void + { + $dto = new FileDTO(url: 'https://invalid.com/file.pdf', name: 'TestFile'); + $type = new AttachmentType(); + + + $entity = $this->service->convertFile($dto, $type); + + $this->assertEquals($dto->name, $entity->getName()); + $this->assertEquals($dto->url, $entity->getUrl()); + $this->assertEquals($type, $entity->getAttachmentType()); + } + + public function testConvertFileWithoutName(): void + { + $dto = new FileDTO(url: 'https://invalid.invalid/file.pdf'); + $type = new AttachmentType(); + + + $entity = $this->service->convertFile($dto, $type); + + //If no name is given, the name is derived from the url + $this->assertEquals('file.pdf', $entity->getName()); + $this->assertEquals($dto->url, $entity->getUrl()); + $this->assertEquals($type, $entity->getAttachmentType()); + } + + public function testConvertPart() + { + $parameters = [new ParameterDTO('Test', 'Test')]; + $datasheets = [new FileDTO('https://invalid.invalid/file.pdf')]; + $images = [new FileDTO('https://invalid.invalid/image.png')]; + $shopping_infos = [new PurchaseInfoDTO('TestDistributor', 'TestOrderNumber', [new PriceDTO(1, "10.0", 'EUR')])]; + + $dto = new PartDetailDTO( + provider_key: 'test_provider', provider_id: 'test_id', provider_url: 'https://invalid.invalid/test_id', + name: 'TestPart', description: 'TestDescription', category: 'TestCategory', + manufacturer: 'TestManufacturer', mpn: 'TestMPN', manufacturing_status: ManufacturingStatus::EOL, + preview_image_url: 'https://invalid.invalid/image.png', + footprint: 'DIP8', notes: 'TestNotes', mass: 10.4, + parameters: $parameters, datasheets: $datasheets, vendor_infos: $shopping_infos, images: $images + ); + + $entity = $this->service->convertPart($dto); + + $this->assertSame($dto->name, $entity->getName()); + $this->assertSame($dto->description, $entity->getDescription()); + $this->assertSame($dto->notes, $entity->getComment()); + + $this->assertSame($dto->manufacturer, $entity->getManufacturer()->getName()); + $this->assertSame($dto->mpn, $entity->getManufacturerProductNumber()); + $this->assertSame($dto->manufacturing_status, $entity->getManufacturingStatus()); + + $this->assertEquals($dto->mass, $entity->getMass()); + $this->assertEquals($dto->footprint, $entity->getFootprint()); + + //We just check that the lenghts of parameters, datasheets, images and shopping infos are the same + //The actual content is tested in the corresponding tests + $this->assertCount(count($parameters), $entity->getParameters()); + $this->assertCount(count($shopping_infos), $entity->getOrderdetails()); + } +} diff --git a/tests/Services/InfoProviderSystem/ProviderRegistryTest.php b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php new file mode 100644 index 00000000..f25d89e4 --- /dev/null +++ b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php @@ -0,0 +1,108 @@ +. + */ + +namespace App\Tests\Services\InfoProviderSystem; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use PHPUnit\Framework\TestCase; + +class ProviderRegistryTest extends TestCase +{ + + /** @var InfoProviderInterface[] */ + private array $providers = []; + + public function setUp(): void + { + //Create some mock providers + $this->providers = [ + $this->getMockProvider('test1'), + $this->getMockProvider('test2'), + $this->getMockProvider('test3', false), + ]; + } + + public function getMockProvider(string $key, bool $active = true): InfoProviderInterface + { + $mock = $this->createMock(InfoProviderInterface::class); + $mock->method('getProviderKey')->willReturn($key); + $mock->method('isActive')->willReturn($active); + + return $mock; + } + + public function testGetProviders(): void + { + $registry = new ProviderRegistry($this->providers); + + $this->assertEquals( + [ + 'test1' => $this->providers[0], + 'test2' => $this->providers[1], + 'test3' => $this->providers[2], + ], + $registry->getProviders()); + } + + public function testGetDisabledProviders(): void + { + $registry = new ProviderRegistry($this->providers); + + $this->assertEquals( + [ + 'test3' => $this->providers[2], + ], + $registry->getDisabledProviders()); + } + + public function testGetActiveProviders(): void + { + $registry = new ProviderRegistry($this->providers); + + $this->assertEquals( + [ + 'test1' => $this->providers[0], + 'test2' => $this->providers[1], + ], + $registry->getActiveProviders()); + } + + public function testGetProviderByKey(): void + { + $registry = new ProviderRegistry($this->providers); + + $this->assertEquals( + $this->providers[0], + $registry->getProviderByKey('test1') + ); + } + + public function testThrowOnDuplicateKeyOfProviders(): void + { + $this->expectException(\LogicException::class); + + $registry = new ProviderRegistry([ + $this->getMockProvider('test1'), + $this->getMockProvider('test2'), + $this->getMockProvider('test1'), + ]); + } +} From 422fa01c6f7614acc51ee081b94c943066f46303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 21:00:45 +0200 Subject: [PATCH 13/34] Use the initial element for database if the value was not changed. --- .../Helper/StructuralEntityChoiceLoader.php | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php index 5d466a73..df98e6ea 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -59,20 +59,24 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader public function createNewEntitiesFromValue(string $value): array { - if (!$this->options['allow_add']) { - //Always allow the starting element to be added - if ($this->starting_element !== null && $this->starting_element->getID() === null) { - $this->entityManager->persist($this->starting_element); - return [$this->starting_element]; - } - - throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!'); - } - if (trim($value) === '') { throw new \InvalidArgumentException('Cannot create new entity, because the name is empty!'); } + //Check if the value is matching the starting value element, we use the choice_value option to get the name of the starting element + if ($this->starting_element !== null + && $this->starting_element->getID() === null //Element must not be persisted yet + && $this->options['choice_value']($this->starting_element) === $value) { + + //Then reuse the starting element + $this->entityManager->persist($this->starting_element); + return [$this->starting_element]; + } + + if (!$this->options['allow_add']) { + throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!'); + } + $class = $this->options['class']; /** @var StructuralDBElementRepository $repo */ $repo = $this->entityManager->getRepository($class); @@ -103,6 +107,7 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader } /** + * Gets the initial value used to populate the field. * @return AbstractStructuralDBElement|null */ public function getStartingElement(): ?AbstractStructuralDBElement @@ -111,6 +116,7 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader } /** + * Sets the initial value used to populate the field. This will always be an allowed value. * @param AbstractStructuralDBElement|null $starting_element * @return StructuralEntityChoiceLoader */ From 701212239d5e7444e3e3d24c46dfc1955e1cf154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 21:17:10 +0200 Subject: [PATCH 14/34] Use an experimental doctrine/orm version to fix some issues persisting attachments while simutanously creating a new attachment type The circular reference between attachmentTypeAttachment and attachmentType seems to confuse doctrine. This is fixed in the experimental version --- composer.json | 2 +- composer.lock | 101 ++++++++++++++++++++++++++++---------------------- 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/composer.json b/composer.json index 40322374..da6af5d7 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "doctrine/dbal": "^3.4.6", "doctrine/doctrine-bundle": "^2.0", "doctrine/doctrine-migrations-bundle": "^3.0", - "doctrine/orm": "^2.9", + "doctrine/orm": "dev-entity-level-commit-order#44d2a83 as 2.15.3", "dompdf/dompdf": "dev-master#87bea32efe0b0db309e1d31537201f64d5508280 as v2.0.3", "erusev/parsedown": "^1.7", "florianv/swap": "^4.0", diff --git a/composer.lock b/composer.lock index 346a75ac..46bc8e70 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e851e50a8353b7633581464ad286c6f7", + "content-hash": "2996892a0aeaa1363a3d9643551d2e58", "packages": [ { "name": "beberlei/assert", @@ -1556,16 +1556,16 @@ }, { "name": "doctrine/orm", - "version": "2.15.3", + "version": "dev-entity-level-commit-order", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "4c3bd208018c26498e5f682aaad45fa00ea307d5" + "reference": "44d2a83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/4c3bd208018c26498e5f682aaad45fa00ea307d5", - "reference": "4c3bd208018c26498e5f682aaad45fa00ea307d5", + "url": "https://api.github.com/repos/doctrine/orm/zipball/44d2a83", + "reference": "44d2a83", "shasum": "" }, "require": { @@ -1651,9 +1651,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.15.3" + "source": "https://github.com/doctrine/orm/tree/entity-level-commit-order" }, - "time": "2023-06-22T12:36:06+00:00" + "time": "2023-06-28T09:45:39+00:00" }, { "name": "doctrine/persistence", @@ -3094,16 +3094,16 @@ }, { "name": "league/html-to-markdown", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/thephpleague/html-to-markdown.git", - "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1" + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", - "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd", "shasum": "" }, "require": { @@ -3113,11 +3113,11 @@ }, "require-dev": { "mikehaertl/php-shellcommand": "^1.1.0", - "phpstan/phpstan": "^0.12.99", + "phpstan/phpstan": "^1.8.8", "phpunit/phpunit": "^8.5 || ^9.2", "scrutinizer/ocular": "^1.6", - "unleashedtech/php-coding-standard": "^2.7", - "vimeo/psalm": "^4.22" + "unleashedtech/php-coding-standard": "^2.7 || ^3.0", + "vimeo/psalm": "^4.22 || ^5.0" }, "bin": [ "bin/html-to-markdown" @@ -3159,7 +3159,7 @@ ], "support": { "issues": "https://github.com/thephpleague/html-to-markdown/issues", - "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.0" + "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1" }, "funding": [ { @@ -3179,7 +3179,7 @@ "type": "tidelift" } ], - "time": "2022-03-02T17:24:08+00:00" + "time": "2023-07-12T21:21:09+00:00" }, { "name": "liip/imagine-bundle", @@ -13110,7 +13110,7 @@ }, { "name": "web-auth/metadata-service", - "version": "4.6.3", + "version": "4.6.4", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-metadata-service.git", @@ -13175,7 +13175,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-metadata-service/tree/4.6.3" + "source": "https://github.com/web-auth/webauthn-metadata-service/tree/4.6.4" }, "funding": [ { @@ -13191,16 +13191,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "4.6.3", + "version": "4.6.4", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "e0f85f09b4e1a48169352290e7ccfd29ade93e34" + "reference": "8cb4949d81ef8414c82f334fb3514141aa013340" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e0f85f09b4e1a48169352290e7ccfd29ade93e34", - "reference": "e0f85f09b4e1a48169352290e7ccfd29ade93e34", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/8cb4949d81ef8414c82f334fb3514141aa013340", + "reference": "8cb4949d81ef8414c82f334fb3514141aa013340", "shasum": "" }, "require": { @@ -13263,7 +13263,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/4.6.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/4.6.4" }, "funding": [ { @@ -13275,11 +13275,11 @@ "type": "patreon" } ], - "time": "2023-06-12T14:32:32+00:00" + "time": "2023-07-15T14:53:06+00:00" }, { "name": "web-auth/webauthn-symfony-bundle", - "version": "4.6.3", + "version": "4.6.4", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-symfony-bundle.git", @@ -13343,7 +13343,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/4.6.3" + "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/4.6.4" }, "funding": [ { @@ -14782,16 +14782,16 @@ }, { "name": "rector/rector", - "version": "0.17.4", + "version": "0.17.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "14829888274eebddc67a0d7248c3dd2965704fbc" + "reference": "ec40080b9bdaf39eb0c0a9276cd7b4a778c03f21" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/14829888274eebddc67a0d7248c3dd2965704fbc", - "reference": "14829888274eebddc67a0d7248c3dd2965704fbc", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/ec40080b9bdaf39eb0c0a9276cd7b4a778c03f21", + "reference": "ec40080b9bdaf39eb0c0a9276cd7b4a778c03f21", "shasum": "" }, "require": { @@ -14831,7 +14831,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/0.17.4" + "source": "https://github.com/rectorphp/rector/tree/0.17.6" }, "funding": [ { @@ -14839,7 +14839,7 @@ "type": "github" } ], - "time": "2023-07-11T16:00:46+00:00" + "time": "2023-07-14T09:54:15+00:00" }, { "name": "roave/security-advisories", @@ -14847,12 +14847,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "bcc78ca7e0e2bf8f2f8afd4eb9aabb988d593c21" + "reference": "63f15424de3fd93ab776497787df3bb2eded004b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/bcc78ca7e0e2bf8f2f8afd4eb9aabb988d593c21", - "reference": "bcc78ca7e0e2bf8f2f8afd4eb9aabb988d593c21", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/63f15424de3fd93ab776497787df3bb2eded004b", + "reference": "63f15424de3fd93ab776497787df3bb2eded004b", "shasum": "" }, "conflict": { @@ -14912,6 +14912,7 @@ "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|= 1.3.7|>=4.1,<4.1.4", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", + "cardgate/woocommerce": "<=3.1.15", "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", "cartalyst/sentry": "<=2.1.6", "catfan/medoo": "<1.7.5", @@ -15033,6 +15034,7 @@ "grumpydictator/firefly-iii": "<6", "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5", + "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "harvesthq/chosen": "<1.8.7", "helloxz/imgurl": "= 2.31|<=2.31", "hhxsv5/laravel-s": "<3.7.36", @@ -15054,7 +15056,7 @@ "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", - "impresscms/impresscms": "<=1.4.3", + "impresscms/impresscms": "<=1.4.5", "in2code/femanager": "<5.5.3|>=6,<6.3.4|>=7,<7.1", "in2code/ipandlanguageredirect": "<5.1.2", "in2code/lux": "<17.6.1|>=18,<24.0.2", @@ -15105,7 +15107,7 @@ "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", "luyadev/yii-helpers": "<1.2.1", - "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3", + "magento/community-edition": "<=2.4", "magento/magento1ce": "<1.9.4.3", "magento/magento1ee": ">=1,<1.14.4.3", "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", @@ -15121,14 +15123,14 @@ "melisplatform/melis-front": "<5.0.1", "mezzio/mezzio-swoole": "<3.7|>=4,<4.3", "mgallegos/laravel-jqgrid": "<=1.3", - "microweber/microweber": "<=1.3.4", + "microweber/microweber": "= 1.1.18|<=1.3.4", "miniorange/miniorange-saml": "<1.4.3", "mittwald/typo3_forum": "<1.2.1", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<= 2.8.3-pl|<2.8", "mojo42/jirafeau": "<4.4", "monolog/monolog": ">=1.8,<1.12", - "moodle/moodle": "<4.2-rc.2|= 4.2.0|= 3.11", + "moodle/moodle": "<4.2-rc.2|= 3.7|= 3.9|= 3.8|= 4.2.0|= 3.11", "movim/moxl": ">=0.8,<=0.10", "mustache/mustache": ">=2,<2.14.1", "namshi/jose": "<2.2", @@ -15160,7 +15162,7 @@ "openid/php-openid": "<2.3", "openmage/magento-lts": "<19.4.22|>=20,<20.0.19", "opensource-workshop/connect-cms": "<1.7.2|>=2,<2.3.2", - "orchid/platform": ">=9,<9.4.4", + "orchid/platform": ">=9,<9.4.4|>=14-alpha.4,<14.5", "oro/commerce": ">=4.1,<5.0.6", "oro/crm": ">=1.7,<1.7.4|>=3.1,<4.1.17|>=4.2,<4.2.7", "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<4.2.8", @@ -15178,7 +15180,7 @@ "personnummer/personnummer": "<3.0.2", "phanan/koel": "<5.1.4", "php-mod/curl": "<2.3.2", - "phpbb/phpbb": ">=3.2,<3.2.10|>=3.3,<3.3.1", + "phpbb/phpbb": "<3.2.10|>=3.3,<3.3.1", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", @@ -15194,13 +15196,14 @@ "phpxmlrpc/extras": "<0.6.1", "phpxmlrpc/phpxmlrpc": "<4.9.2", "pi/pi": "<=2.5", + "pimcore/admin-ui-classic-bundle": "<1.0.3", "pimcore/customer-management-framework-bundle": "<3.4.1", "pimcore/data-hub": "<1.2.4", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<10.5.23", + "pimcore/pimcore": "<10.5.24", "pixelfed/pixelfed": "<=0.11.4", "pocketmine/bedrock-protocol": "<8.0.2", - "pocketmine/pocketmine-mp": "<4.20.5|>=4.21,<4.21.1|< 4.18.0-ALPHA2|>= 4.0.0-BETA5, < 4.4.2", + "pocketmine/pocketmine-mp": "<4.22.3|>=5,<5.2.1|< 4.18.0-ALPHA2|>= 4.0.0-BETA5, < 4.4.2", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockwishlist": ">=2,<2.1.1", @@ -15351,7 +15354,7 @@ "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", "twig/twig": "<1.44.7|>=2,<2.15.3|>=3,<3.4.3", - "typo3/cms": "<2.0.5|>=3,<3.0.3|>=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.38|>=9,<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", + "typo3/cms": "<2.0.5|>=3,<3.0.3|>=6.2,<=6.2.38|>=7,<7.6.32|>=8,<8.7.38|>=9,<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms-backend": ">=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", "typo3/cms-core": "<8.7.51|>=9,<9.5.40|>=10,<10.4.36|>=11,<11.5.23|>=12,<12.2", "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", @@ -15429,6 +15432,7 @@ "zendframework/zendframework1": "<1.12.20", "zendframework/zendopenid": ">=2,<2.0.2", "zendframework/zendxml": ">=1,<1.0.1", + "zenstruck/collection": "<0.2.1", "zetacomponents/mail": "<1.8.2", "zf-commons/zfc-user": "<1.2.2", "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", @@ -15471,7 +15475,7 @@ "type": "tidelift" } ], - "time": "2023-07-11T01:32:50+00:00" + "time": "2023-07-14T22:04:26+00:00" }, { "name": "sebastian/diff", @@ -16234,6 +16238,12 @@ } ], "aliases": [ + { + "package": "doctrine/orm", + "version": "dev-entity-level-commit-order", + "alias": "2.15.3", + "alias_normalized": "2.15.3.0" + }, { "package": "dompdf/dompdf", "version": "9999999-dev", @@ -16243,6 +16253,7 @@ ], "minimum-stability": "stable", "stability-flags": { + "doctrine/orm": 20, "dompdf/dompdf": 20, "florianv/swap-bundle": 20, "roave/security-advisories": 20 From db97114fb44a9af2ff18d2214de516142e542d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 15 Jul 2023 21:41:35 +0200 Subject: [PATCH 15/34] Use preview image and other additional images provided by the info provider --- .../DTOtoEntityConverter.php | 24 +++++++++++++++++++ .../Providers/TMEProvider.php | 17 ++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index b0e10d6a..5518c880 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -162,6 +162,30 @@ final class DTOtoEntityConverter $entity->addParameter($this->convertParameter($parameter)); } + //Add preview image + $image_type = $this->getImageType(); + + if ($dto->preview_image_url) { + $preview_image = new PartAttachment(); + $preview_image->setURL($dto->preview_image_url); + $preview_image->setName('Main image'); + $preview_image->setAttachmentType($image_type); + + $entity->addAttachment($preview_image); + $entity->setMasterPictureAttachment($preview_image); + } + + //Add other images + foreach ($dto->images ?? [] as $image) { + //Ensure that the image is not the same as the preview image + if ($image->url === $dto->preview_image_url) { + continue; + } + + $entity->addAttachment($this->convertFile($image, $image_type)); + } + + //Add datasheets $datasheet_type = $this->getDatasheetType(); foreach ($dto->datasheets ?? [] as $datasheet) { diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 0662175c..1540e82a 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -86,7 +86,7 @@ class TMEProvider implements InfoProviderInterface $result[] = new SearchResultDTO( provider_key: $this->getProviderKey(), provider_id: $product['Symbol'], - name: $product['OriginalSymbol'] ?? $product['Symbol'], + name: !empty($product['OriginalSymbol']) ? $product['OriginalSymbol'] : $product['Symbol'], description: $product['Description'], category: $product['Category'], manufacturer: $product['Producer'], @@ -122,7 +122,7 @@ class TMEProvider implements InfoProviderInterface return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $product['Symbol'], - name: $product['OriginalSymbol'] ?? $product['Symbol'], + name: !empty($product['OriginalSymbol']) ? $product['OriginalSymbol'] : $product['Symbol'], description: $product['Description'], category: $product['Category'], manufacturer: $product['Producer'], @@ -132,6 +132,7 @@ class TMEProvider implements InfoProviderInterface provider_url: $productInfoPage, footprint: $footprint, datasheets: $files['datasheets'], + images: $files['images'], parameters: $parameters, vendor_infos: [$this->getVendorInfo($id, $productInfoPage)], mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null, @@ -142,7 +143,7 @@ class TMEProvider implements InfoProviderInterface * Fetches all files for a given product id * @param string $id * @return array> An array with the keys 'datasheet' - * @phpstan-return array{datasheets: list} + * @phpstan-return array{datasheets: list, images: list} */ public function getFiles(string $id): array { @@ -164,9 +165,19 @@ class TMEProvider implements InfoProviderInterface ); } + //Extract images + $imageList = $files['AdditionalPhotoList']; + $images = []; + foreach($imageList as $image) { + $images[] = new FileDTO( + url: $this->normalizeURL($image['HighResolutionPhoto']), + ); + } + return [ 'datasheets' => $datasheets, + 'images' => $images, ]; } From a95ba1acc407b4a67d2bd9b02e41ff4a1d40e1eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 01:24:49 +0200 Subject: [PATCH 16/34] Add a reference to the used info provider to a part --- migrations/Version20230715225205.php | 33 ++++ src/Entity/OAuthToken.php | 67 ++++++++ src/Entity/Parts/InfoProviderReference.php | 155 ++++++++++++++++++ src/Entity/Parts/Part.php | 6 + .../PartTraits/AdvancedPropertyTrait.php | 29 ++++ .../DTOtoEntityConverter.php | 4 + src/Twig/InfoProviderExtension.php | 72 ++++++++ .../parts/info/_extended_infos.html.twig | 23 +++ templates/parts/info/_sidebar.html.twig | 12 ++ .../Parts/InfoProviderReferenceTest.php | 68 ++++++++ translations/messages.en.xlf | 12 ++ 11 files changed, 481 insertions(+) create mode 100644 migrations/Version20230715225205.php create mode 100644 src/Entity/OAuthToken.php create mode 100644 src/Entity/Parts/InfoProviderReference.php create mode 100644 src/Twig/InfoProviderExtension.php create mode 100644 tests/Entity/Parts/InfoProviderReferenceTest.php diff --git a/migrations/Version20230715225205.php b/migrations/Version20230715225205.php new file mode 100644 index 00000000..03cdc930 --- /dev/null +++ b/migrations/Version20230715225205.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE oauth_tokens (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(255) DEFAULT NULL, expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', refresh_token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX oauth_tokens_unique_name (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE parts ADD provider_reference_provider_key VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_id VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_url VARCHAR(255) DEFAULT NULL, ADD provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE oauth_tokens'); + $this->addSql('ALTER TABLE `parts` DROP provider_reference_provider_key, DROP provider_reference_provider_id, DROP provider_reference_provider_url, DROP provider_reference_last_updated'); + } +} diff --git a/src/Entity/OAuthToken.php b/src/Entity/OAuthToken.php new file mode 100644 index 00000000..9fbacb46 --- /dev/null +++ b/src/Entity/OAuthToken.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Base\AbstractNamedDBElement; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * This entity represents a OAuth token pair (access and refresh token), for an application + */ +#[ORM\Entity()] +#[ORM\Table(name: 'oauth_tokens')] +#[ORM\UniqueConstraint(name: 'oauth_tokens_unique_name', columns: ['name'])] +#[ORM\Index(columns: ['name'], name: 'oauth_tokens_name_idx')] +class OAuthToken extends AbstractNamedDBElement +{ + /** @var string|null The short-term usable OAuth2 token */ + #[ORM\Column(type: 'string', nullable: true)] + private ?string $token = null; + + /** @var \DateTimeInterface The date when the token expires */ + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeInterface $expires_at = null; + + /** @var string The refresh token for the OAuth2 auth */ + #[ORM\Column(type: 'string')] + private string $refresh_token = ''; + + public function __construct(string $name, string $refresh_token, string $token = null, \DateTimeInterface $expires_at = null) + { + parent::__construct(); + + //If token is given, you also have to give the expires_at date + if ($token !== null && $expires_at === null) { + throw new \InvalidArgumentException('If you give a token, you also have to give the expires_at date'); + } + + $this->name = $name; + $this->refresh_token = $refresh_token; + $this->expires_at = $expires_at; + $this->token = $token; + } + +} \ No newline at end of file diff --git a/src/Entity/Parts/InfoProviderReference.php b/src/Entity/Parts/InfoProviderReference.php new file mode 100644 index 00000000..26e23d34 --- /dev/null +++ b/src/Entity/Parts/InfoProviderReference.php @@ -0,0 +1,155 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity\Parts; + +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping\Column; +use Doctrine\ORM\Mapping\Embeddable; + +/** + * This class represents a reference to a info provider inside a part. + */ +#[Embeddable] +class InfoProviderReference +{ + + /** @var string|null The key referencing the provider used to get this part, or null if it was not provided by a data provider */ + #[Column(type: 'string', nullable: true)] + private ?string $provider_key = null; + + /** @var string|null The id of this part inside the provider system or null if the part was not provided by a data provider */ + #[Column(type: 'string', nullable: true)] + private ?string $provider_id = null; + + /** + * @var string|null The url of this part inside the provider system or null if this info is not existing + */ + #[Column(type: 'string', nullable: true)] + private ?string $provider_url = null; + + #[Column(type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])] + private ?\DateTimeInterface $last_updated = null; + + /** + * Constructing is forbidden from outside. + */ + private function __construct() + { + + } + + /** + * Returns the key usable to identify the provider, which provided this part. Returns null, if the part was not created by a provider. + * @return string|null + */ + public function getProviderKey(): ?string + { + return $this->provider_key; + } + + /** + * Returns the id of this part inside the provider system or null if the part was not provided by a data provider. + * @return string|null + */ + public function getProviderId(): ?string + { + return $this->provider_id; + } + + /** + * Returns the url of this part inside the provider system or null if this info is not existing. + * @return string|null + */ + public function getProviderUrl(): ?string + { + return $this->provider_url; + } + + /** + * Gets the time, when the part was last time updated by the provider. + * @return \DateTimeInterface|null + */ + public function getLastUpdated(): ?\DateTimeInterface + { + return $this->last_updated; + } + + /** + * Returns true, if this part was created based on infos from a provider. + * Or false, if this part was created by a user manually. + * @return bool + */ + public function isProviderCreated(): bool + { + return $this->provider_key !== null; + } + + /** + * Creates a new instance, without any provider information. + * Use this for parts, which are created by a user manually. + * @return InfoProviderReference + */ + public static function noProvider(): self + { + $ref = new InfoProviderReference(); + $ref->provider_key = null; + $ref->provider_id = null; + $ref->provider_url = null; + $ref->last_updated = null; + return $ref; + } + + /** + * Creates a reference to an info provider based on the given parameters. + * @param string $provider_key + * @param string $provider_id + * @param string|null $provider_url + * @return self + */ + public static function providerReference(string $provider_key, string $provider_id, ?string $provider_url = null): self + { + $ref = new InfoProviderReference(); + $ref->provider_key = $provider_key; + $ref->provider_id = $provider_id; + $ref->provider_url = $provider_url; + $ref->last_updated = new \DateTimeImmutable(); + return $ref; + } + + /** + * Creates a reference to an info provider based on the given Part DTO + * @param SearchResultDTO $dto + * @return self + */ + public static function fromPartDTO(SearchResultDTO $dto): self + { + $ref = new InfoProviderReference(); + $ref->provider_key = $dto->provider_key; + $ref->provider_id = $dto->provider_id; + $ref->provider_url = $dto->provider_url; + $ref->last_updated = new \DateTimeImmutable(); + return $ref; + } +} \ No newline at end of file diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 9279ec11..2826e5fe 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -114,6 +114,9 @@ class Part extends AttachmentContainingDBElement $this->orderdetails = new ArrayCollection(); $this->parameters = new ArrayCollection(); $this->project_bom_entries = new ArrayCollection(); + + //By default, the part has no provider + $this->providerReference = InfoProviderReference::noProvider(); } public function __clone() @@ -139,6 +142,9 @@ class Part extends AttachmentContainingDBElement foreach ($parameters as $parameter) { $this->addParameter(clone $parameter); } + + //Deep clone info provider + $this->providerReference = clone $this->providerReference; } parent::__clone(); } diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 633bf9d0..51bce445 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Entity\Parts\PartTraits; +use App\Entity\Parts\InfoProviderReference; use Doctrine\DBAL\Types\Types; use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; @@ -63,6 +64,12 @@ trait AdvancedPropertyTrait #[ORM\Column(type: Types::STRING, length: 100, nullable: true, unique: true)] protected ?string $ipn = null; + /** + * @var InfoProviderReference The reference to the info provider, that provided the information about this part + */ + #[ORM\Embedded(class: InfoProviderReference::class, columnPrefix: 'provider_reference_')] + protected InfoProviderReference $providerReference; + /** * Checks if this part is marked, for that it needs further review. */ @@ -150,5 +157,27 @@ trait AdvancedPropertyTrait return $this; } + /** + * Returns the reference to the info provider, that provided the information about this part. + * @return InfoProviderReference + */ + public function getProviderReference(): InfoProviderReference + { + return $this->providerReference; + } + + /** + * Sets the reference to the info provider, that provided the information about this part. + * @param InfoProviderReference $providerReference + * @return AdvancedPropertyTrait + */ + public function setProviderReference(InfoProviderReference $providerReference): self + { + $this->providerReference = $providerReference; + return $this; + } + + + } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 5518c880..0c7a639d 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -29,6 +29,7 @@ use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\PartParameter; use App\Entity\Parts\Footprint; +use App\Entity\Parts\InfoProviderReference; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; @@ -157,6 +158,9 @@ final class DTOtoEntityConverter $entity->setManufacturerProductNumber($dto->mpn ?? ''); $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); + //Set the provider reference on the part + $entity->setProviderReference(InfoProviderReference::fromPartDTO($dto)); + //Add parameters foreach ($dto->parameters ?? [] as $parameter) { $entity->addParameter($this->convertParameter($parameter)); diff --git a/src/Twig/InfoProviderExtension.php b/src/Twig/InfoProviderExtension.php new file mode 100644 index 00000000..7cb04db4 --- /dev/null +++ b/src/Twig/InfoProviderExtension.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Twig; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +class InfoProviderExtension extends AbstractExtension +{ + public function __construct( + private readonly ProviderRegistry $providerRegistry + ) {} + + public function getFunctions(): array + { + return [ + new TwigFunction('info_provider', $this->getInfoProvider(...)), + new TwigFunction('info_provider_label', $this->getInfoProviderName(...)) + ]; + } + + /** + * Gets the info provider with the given key. Returns null, if the provider does not exist. + * @param string $key + * @return InfoProviderInterface|null + */ + private function getInfoProvider(string $key): ?InfoProviderInterface + { + try { + return $this->providerRegistry->getProviderByKey($key); + } catch (\InvalidArgumentException $exception) { + return null; + } + } + + /** + * Gets the label of the info provider with the given key. Returns null, if the provider does not exist. + * @param string $key + * @return string|null + */ + private function getInfoProviderName(string $key): ?string + { + try { + return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()['name']; + } catch (\InvalidArgumentException $exception) { + return null; + } + } +} \ No newline at end of file diff --git a/templates/parts/info/_extended_infos.html.twig b/templates/parts/info/_extended_infos.html.twig index e0bb01d7..b80a1a9a 100644 --- a/templates/parts/info/_extended_infos.html.twig +++ b/templates/parts/info/_extended_infos.html.twig @@ -62,5 +62,28 @@ {% endif %} + + + {% trans %}part.info_provider_reference{% endtrans %} + + {% if part.providerReference.providerCreated %} + {% if part.providerReference.providerUrl %} + + {% endif %} + {{ info_provider_label(part.providerReference.providerKey)|default(part.providerReference.providerKey) }}: {{ part.providerReference.providerId }} + ({{ part.providerReference.lastUpdated | format_datetime() }}) + {% if part.providerReference.providerUrl %} + + {% endif %} + + {# Show last updated date #} + + {% else %} + {{ helper.boolean_badge(part.providerReference.providerCreated) }} + {% endif %} + + + + \ No newline at end of file diff --git a/templates/parts/info/_sidebar.html.twig b/templates/parts/info/_sidebar.html.twig index 5fb8caef..28eada04 100644 --- a/templates/parts/info/_sidebar.html.twig +++ b/templates/parts/info/_sidebar.html.twig @@ -67,4 +67,16 @@ {{ helper.string_to_tags(part.tags) }} +{% endif %} + +{# Info provider badge #} +{% if part.providerReference.providerCreated %} + {% endif %} \ No newline at end of file diff --git a/tests/Entity/Parts/InfoProviderReferenceTest.php b/tests/Entity/Parts/InfoProviderReferenceTest.php new file mode 100644 index 00000000..365eb68c --- /dev/null +++ b/tests/Entity/Parts/InfoProviderReferenceTest.php @@ -0,0 +1,68 @@ +. + */ + +namespace App\Tests\Entity\Parts; + +use App\Entity\Parts\InfoProviderReference; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use PHPUnit\Framework\TestCase; + +class InfoProviderReferenceTest extends TestCase +{ + public function testNoProvider(): void + { + $provider = InfoProviderReference::noProvider(); + + //The no provider instance should return false for the providerCreated method + $this->assertFalse($provider->isProviderCreated()); + //And null for all other methods + $this->assertNull($provider->getProviderKey()); + $this->assertNull($provider->getProviderId()); + $this->assertNull($provider->getProviderUrl()); + $this->assertNull($provider->getLastUpdated()); + } + + public function testProviderReference(): void + { + $provider = InfoProviderReference::providerReference('test', 'id', 'url'); + + //The provider reference instance should return true for the providerCreated method + $this->assertTrue($provider->isProviderCreated()); + //And the correct values for all other methods + $this->assertEquals('test', $provider->getProviderKey()); + $this->assertEquals('id', $provider->getProviderId()); + $this->assertEquals('url', $provider->getProviderUrl()); + $this->assertNotNull($provider->getLastUpdated()); + } + + public function testFromPartDTO(): void + { + $dto = new PartDetailDTO(provider_key: 'test', provider_id: 'id', name: 'name', description: 'description', provider_url: 'url'); + $reference = InfoProviderReference::fromPartDTO($dto); + + //The provider reference instance should return true for the providerCreated method + $this->assertTrue($reference->isProviderCreated()); + //And the correct values for all other methods + $this->assertEquals('test', $reference->getProviderKey()); + $this->assertEquals('id', $reference->getProviderId()); + $this->assertEquals('url', $reference->getProviderUrl()); + $this->assertNotNull($reference->getLastUpdated()); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index be059355..049480e5 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11483,5 +11483,17 @@ Please note, that you can not impersonate a disabled user. If you try you will g Prices + + + part.info_provider_reference.badge + The information provider used to create this part. + + + + + part.info_provider_reference + Created by Information provider + + From c203de082e63932ef3dda23e08d966be4836a40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 03:07:53 +0200 Subject: [PATCH 17/34] Added proper OAuth authentication for digikey and other providers --- composer.json | 1 + composer.lock | 551 +++++++++++++++++- config/bundles.php | 1 + config/packages/http_client.yaml | 14 +- config/packages/knpu_oauth2_client.yaml | 18 + config/packages/nelmio_security.yaml | 3 + config/services.yaml | 5 + src/Controller/OAuthClientController.php | 63 ++ src/Entity/OAuthToken.php | 73 ++- .../LogSystem/EventLoggerSubscriber.php | 6 + .../Providers/DigikeyProvider.php | 20 +- src/Services/OAuth/OAuthTokenManager.php | 128 ++++ symfony.lock | 12 + 13 files changed, 876 insertions(+), 19 deletions(-) create mode 100644 config/packages/knpu_oauth2_client.yaml create mode 100644 src/Controller/OAuthClientController.php create mode 100644 src/Services/OAuth/OAuthTokenManager.php diff --git a/composer.json b/composer.json index da6af5d7..fd96da4b 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "jbtronics/2fa-webauthn": "^v2.0.0", "jbtronics/dompdf-font-loader-bundle": "^1.0.0", "jfcherng/php-diff": "^6.14", + "knpuniversity/oauth2-client-bundle": "^2.15", "league/csv": "^9.8.0", "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", diff --git a/composer.lock b/composer.lock index 46bc8e70..99333e7d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2996892a0aeaa1363a3d9643551d2e58", + "content-hash": "1c3a6a5bba2865b104630aaf4336e483", "packages": [ { "name": "beberlei/assert", @@ -2393,6 +2393,331 @@ }, "time": "2022-01-11T08:28:06+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.7.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "fb7566caccf22d74d1ab270de3551f72a58399f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/fb7566caccf22d74d1ab270de3551f72a58399f5", + "reference": "fb7566caccf22d74d1ab270de3551f72a58399f5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "ext-curl": "*", + "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.29 || ^9.5.23", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2023-05-21T14:04:53+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "phpunit/phpunit": "^8.5.29 || ^9.5.23" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2023-05-21T13:50:22+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "b635f279edd83fc275f822a1188157ffea568ff6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", + "reference": "b635f279edd83fc275f822a1188157ffea568ff6", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^8.5.29 || ^9.5.23" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2023-04-17T16:11:26+00:00" + }, { "name": "imagine/imagine", "version": "1.3.5", @@ -2806,6 +3131,66 @@ ], "time": "2023-05-21T07:57:08+00:00" }, + { + "name": "knpuniversity/oauth2-client-bundle", + "version": "v2.15.0", + "source": { + "type": "git", + "url": "https://github.com/knpuniversity/oauth2-client-bundle.git", + "reference": "9df0736d02eb20b953ec8e9986743611747d9ed9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/9df0736d02eb20b953ec8e9986743611747d9ed9", + "reference": "9df0736d02eb20b953ec8e9986743611747d9ed9", + "shasum": "" + }, + "require": { + "league/oauth2-client": "^2.0", + "php": ">=7.4", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/framework-bundle": "^4.4|^5.0|^6.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0", + "symfony/routing": "^4.4|^5.0|^6.0" + }, + "require-dev": { + "league/oauth2-facebook": "^1.1|^2.0", + "phpstan/phpstan": "^0.12", + "symfony/phpunit-bridge": "^5.3.1|^6.0", + "symfony/security-guard": "^4.4|^5.0|^6.0", + "symfony/yaml": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/security-guard": "For integration with Symfony's Guard Security layer" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "KnpU\\OAuth2ClientBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "email": "ryan@symfonycasts.com" + } + ], + "description": "Integration with league/oauth2-client to provide services", + "homepage": "https://symfonycasts.com", + "keywords": [ + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues", + "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.15.0" + }, + "time": "2023-05-03T16:44:38+00:00" + }, { "name": "laminas/laminas-code", "version": "4.11.0", @@ -3181,6 +3566,76 @@ ], "time": "2023-07-12T21:21:09+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + }, + "time": "2023-04-16T18:19:15+00:00" + }, { "name": "liip/imagine-bundle", "version": "2.11.0", @@ -4197,6 +4652,56 @@ }, "time": "2022-06-14T06:56:20+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "part-db/label-fonts", "version": "v1.0.0", @@ -5533,6 +6038,50 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "robrichards/xmlseclibs", "version": "3.1.1", diff --git a/config/bundles.php b/config/bundles.php index 89a63165..6545338d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -30,4 +30,5 @@ return [ Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Jbtronics\DompdfFontLoaderBundle\DompdfFontLoaderBundle::class => ['all' => true], + KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], ]; diff --git a/config/packages/http_client.yaml b/config/packages/http_client.yaml index 9489e92b..2e693f7f 100644 --- a/config/packages/http_client.yaml +++ b/config/packages/http_client.yaml @@ -2,16 +2,4 @@ framework: http_client: default_options: headers: - 'User-Agent': 'Part-DB' - - - scoped_clients: - digikey.client: - base_uri: 'https://sandbox-api.digikey.com' - auth_bearer: '%env(PROVIDER_DIGIKEY_TOKEN)%' - headers: - X-DIGIKEY-Client-Id: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' - X-DIGIKEY-Locale-Site: 'DE' - X-DIGIKEY-Locale-Language: 'de' - X-DIGIKEY-Locale-Currency: '%partdb.default_currency%' - X-DIGIKEY-Customer-Id: 0 \ No newline at end of file + 'User-Agent': 'Part-DB' \ No newline at end of file diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml new file mode 100644 index 00000000..c59fe206 --- /dev/null +++ b/config/packages/knpu_oauth2_client.yaml @@ -0,0 +1,18 @@ +knpu_oauth2_client: + clients: + # configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration + + ip_digikey_oauth: + type: generic + provider_class: '\League\OAuth2\Client\Provider\GenericProvider' + + client_id: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' + client_secret: '%env(PROVIDER_DIGIKEY_SECRET)%' + + redirect_route: 'oauth_client_check' + redirect_params: {name: 'ip_digikey_oauth'} + + provider_options: + urlAuthorize: 'https://sandbox-api.digikey.com/v1/oauth2/authorize' + urlAccessToken: 'https://sandbox-api.digikey.com/v1/oauth2/token' + urlResourceOwnerDetails: '' diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index c8d24af0..c12fdb8b 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -16,6 +16,9 @@ nelmio_security: # Whitelist the domain of the SAML IDP, so we can redirect to it during the SAML login process - '%env(string:key:host:url:SAML_IDP_SINGLE_SIGN_ON_SERVICE)%' + # Whitelist the info provider APIs + - 'digikey.com' + # forces Microsoft's XSS-Protection with # its block mode xss_protection: diff --git a/config/services.yaml b/config/services.yaml index 54807566..a026f63d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -248,6 +248,11 @@ services: arguments: $api_key: '%env(PROVIDER_ELEMENT14_KEY)%' + App\Services\InfoProviderSystem\Providers\DigikeyProvider: + arguments: + $clientId: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' + $currency: '%partdb.default_currency%' + App\Services\InfoProviderSystem\Providers\TMEClient: arguments: $secret: '%env(PROVIDER_TME_SECRET)%' diff --git a/src/Controller/OAuthClientController.php b/src/Controller/OAuthClientController.php new file mode 100644 index 00000000..0b80a324 --- /dev/null +++ b/src/Controller/OAuthClientController.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\OAuth\OAuthTokenManager; +use KnpU\OAuth2ClientBundle\Client\ClientRegistry; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +use function Symfony\Component\Translation\t; + +#[Route('/oauth/client')] +class OAuthClientController extends AbstractController +{ + public function __construct(private readonly ClientRegistry $clientRegistry, private readonly OAuthTokenManager $tokenManager) + { + + } + + #[Route('/{name}/connect', name: 'oauth_client_connect')] + public function connect(string $name): Response + { + return $this->clientRegistry + ->getClient($name) // key used in config/packages/knpu_oauth2_client.yaml + ->redirect(); + } + + #[Route('/{name}/check', name: 'oauth_client_check')] + public function check(string $name, Request $request): Response + { + $client = $this->clientRegistry->getClient($name); + + $access_token = $client->getAccessToken(); + $this->tokenManager->saveToken($name, $access_token); + + $this->addFlash('success', t('oauth_client.flash.connection_successful')); + + return $this->redirectToRoute('homepage'); + } +} \ No newline at end of file diff --git a/src/Entity/OAuthToken.php b/src/Entity/OAuthToken.php index 9fbacb46..30a8feef 100644 --- a/src/Entity/OAuthToken.php +++ b/src/Entity/OAuthToken.php @@ -27,6 +27,7 @@ use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractNamedDBElement; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use League\OAuth2\Client\Token\AccessTokenInterface; /** * This entity represents a OAuth token pair (access and refresh token), for an application @@ -35,7 +36,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'oauth_tokens')] #[ORM\UniqueConstraint(name: 'oauth_tokens_unique_name', columns: ['name'])] #[ORM\Index(columns: ['name'], name: 'oauth_tokens_name_idx')] -class OAuthToken extends AbstractNamedDBElement +class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface { /** @var string|null The short-term usable OAuth2 token */ #[ORM\Column(type: 'string', nullable: true)] @@ -49,10 +50,10 @@ class OAuthToken extends AbstractNamedDBElement #[ORM\Column(type: 'string')] private string $refresh_token = ''; + private const DEFAULT_EXPIRATION_TIME = 3600; + public function __construct(string $name, string $refresh_token, string $token = null, \DateTimeInterface $expires_at = null) { - parent::__construct(); - //If token is given, you also have to give the expires_at date if ($token !== null && $expires_at === null) { throw new \InvalidArgumentException('If you give a token, you also have to give the expires_at date'); @@ -64,4 +65,70 @@ class OAuthToken extends AbstractNamedDBElement $this->token = $token; } + public static function fromAccessToken(AccessTokenInterface $accessToken, string $name): self + { + return new self( + $name, + $accessToken->getRefreshToken(), + $accessToken->getToken(), + self::unixTimestampToDatetime($accessToken->getExpires() ?? time() + self::DEFAULT_EXPIRATION_TIME) + ); + } + + private static function unixTimestampToDatetime(int $timestamp): \DateTimeInterface + { + return \DateTimeImmutable::createFromFormat('U', (string)$timestamp); + } + + public function getToken(): ?string + { + return $this->token; + } + + public function getExpirationDate(): ?\DateTimeInterface + { + return $this->expires_at; + } + + public function getRefreshToken(): string + { + return $this->refresh_token; + } + + public function isExpired(): bool + { + //null token is always expired + if ($this->token === null) { + return true; + } + + if ($this->expires_at === null) { + return false; + } + + return $this->expires_at->getTimestamp() < time(); + } + + public function replaceWithNewToken(AccessTokenInterface $accessToken): void + { + $this->token = $accessToken->getToken(); + $this->refresh_token = $accessToken->getRefreshToken(); + //If no expiration date is given, we set it to the default expiration time + $this->expires_at = self::unixTimestampToDatetime($accessToken->getExpires() ?? time() + self::DEFAULT_EXPIRATION_TIME); + } + + public function getExpires() + { + return $this->expires_at->getTimestamp(); + } + + public function hasExpired() + { + return $this->isExpired(); + } + + public function getValues() + { + return []; + } } \ No newline at end of file diff --git a/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php b/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php index f48cf749..5a7fd50a 100644 --- a/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php +++ b/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php @@ -29,6 +29,7 @@ use App\Entity\LogSystem\CollectionElementDeleted; use App\Entity\LogSystem\ElementCreatedLogEntry; use App\Entity\LogSystem\ElementDeletedLogEntry; use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\OAuthToken; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\PartLot; use App\Entity\PriceInformations\Orderdetail; @@ -344,6 +345,11 @@ class EventLoggerSubscriber implements EventSubscriber */ protected function validEntity(object $entity): bool { + //Dont log OAuthTokens + if ($entity instanceof OAuthToken) { + return false; + } + //Dont log logentries itself! return $entity instanceof AbstractDBElement && !$entity instanceof AbstractLogEntry; } diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index 26cea80c..b22b18d6 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -26,14 +26,29 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\OAuth\OAuthTokenManager; use Symfony\Contracts\HttpClient\HttpClientInterface; class DigikeyProvider implements InfoProviderInterface { - public function __construct(private readonly HttpClientInterface $digikeyClient) - { + private const OAUTH_APP_NAME = 'ip_digikey_oauth'; + private readonly HttpClientInterface $digikeyClient; + + public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, string $currency, string $clientId) + { + //Create the HTTP client with some default options + $this->digikeyClient = $httpClient->withOptions([ + "base_uri" => 'https://sandbox-api.digikey.com', + "headers" => [ + "X-DIGIKEY-Client-Id" => $clientId, + "X-DIGIKEY-Locale-Site" => 'DE', + "X-DIGIKEY-Locale-Language" => 'de', + "X-DIGIKEY-Locale-Currency" => $currency, + "X-DIGIKEY-Customer-Id" => 0, + ] + ]); } public function getProviderInfo(): array @@ -77,6 +92,7 @@ class DigikeyProvider implements InfoProviderInterface $response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [ 'json' => $request, + 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) ]); $response_array = $response->toArray(); diff --git a/src/Services/OAuth/OAuthTokenManager.php b/src/Services/OAuth/OAuthTokenManager.php new file mode 100644 index 00000000..1e76c8d0 --- /dev/null +++ b/src/Services/OAuth/OAuthTokenManager.php @@ -0,0 +1,128 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\OAuth; + +use App\Entity\OAuthToken; +use Doctrine\ORM\EntityManagerInterface; +use KnpU\OAuth2ClientBundle\Client\ClientRegistry; +use League\OAuth2\Client\Token\AccessTokenInterface; + +final class OAuthTokenManager +{ + public function __construct(private readonly ClientRegistry $clientRegistry, private readonly EntityManagerInterface $entityManager) + { + + } + + /** + * Saves the given token to the database, so it can be retrieved later + * @param string $app_name + * @param AccessTokenInterface $token + * @return void + */ + public function saveToken(string $app_name, AccessTokenInterface $token): void + { + //Check if we already have a token for this app + $tokenEntity = $this->entityManager->getRepository(OAuthToken::class)->findOneBy(['name' => $app_name]); + + //If the token was already existing, we just replace it with the new one + if ($tokenEntity) { + $tokenEntity->replaceWithNewToken($token); + + $this->entityManager->flush($tokenEntity); + + //We are done + return; + } + + //If the token was not existing, we create a new one + $tokenEntity = OAuthToken::fromAccessToken($token, $app_name); + $this->entityManager->persist($tokenEntity); + $this->entityManager->flush($tokenEntity); + + return; + } + + /** + * Returns the token for the given app name + * @param string $app_name + * @return OAuthToken|null + */ + public function getToken(string $app_name): ?OAuthToken + { + return $this->entityManager->getRepository(OAuthToken::class)->findOneBy(['name' => $app_name]); + } + + /** + * This function refreshes the token for the given app name. The new token is saved to the database + * The app_name must be registered in the knpu_oauth2_client.yaml + * @param string $app_name + * @return OAuthToken + * @throws \Exception + */ + public function refreshToken(string $app_name): OAuthToken + { + $token = $this->getToken($app_name); + + if (!$token) { + throw new \Exception('No token was saved yet for '.$app_name); + } + + $client = $this->clientRegistry->getClient($app_name); + $new_token = $client->refreshAccessToken($token->getRefreshToken()); + + //Persist the token + $token->replaceWithNewToken($new_token); + $this->entityManager->flush($token); + + return $token; + } + + /** + * This function returns the token of the given app name + * @param string $app_name + * @return OAuthToken|null + */ + public function getAlwaysValidTokenString(string $app_name): ?string + { + //Get the token for the application + $token = $this->getToken($app_name); + + //If the token is not existing, we return null + if (!$token) { + return null; + } + + //If the token is still valid, we return it + if (!$token->hasExpired()) { + return $token->getToken(); + } + + //If the token is expired, we refresh it + $this->refreshToken($app_name); + + //And return the new token + return $token->getToken(); + } +} \ No newline at end of file diff --git a/symfony.lock b/symfony.lock index ed92f4e1..d47e131c 100644 --- a/symfony.lock +++ b/symfony.lock @@ -180,6 +180,18 @@ "jbtronics/dompdf-font-loader-bundle": { "version": "dev-main" }, + "knpuniversity/oauth2-client-bundle": { + "version": "2.15", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.20", + "ref": "1ff300d8c030f55c99219cc55050b97a695af3f6" + }, + "files": [ + "./config/packages/knpu_oauth2_client.yaml" + ] + }, "laminas/laminas-code": { "version": "3.4.1" }, From f7648e3311289ec8c15332e1f77a71d470fc7bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 03:18:33 +0200 Subject: [PATCH 18/34] Added an button to connect the oauth providers from WebUI --- .../InfoProviderSystem/Providers/DigikeyProvider.php | 6 ++++-- .../Providers/InfoProviderInterface.php | 3 ++- src/Services/OAuth/OAuthTokenManager.php | 10 ++++++++++ templates/info_providers/providers.macro.html.twig | 5 ++++- templates/info_providers/search/part_search.html.twig | 2 ++ translations/messages.en.xlf | 6 ++++++ 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index b22b18d6..f8e968e1 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -36,7 +36,7 @@ class DigikeyProvider implements InfoProviderInterface private readonly HttpClientInterface $digikeyClient; - public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, string $currency, string $clientId) + public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, string $currency, private readonly string $clientId) { //Create the HTTP client with some default options $this->digikeyClient = $httpClient->withOptions([ @@ -57,6 +57,7 @@ class DigikeyProvider implements InfoProviderInterface 'name' => 'DigiKey', 'description' => 'This provider uses the DigiKey API to search for parts.', 'url' => 'https://www.digikey.com/', + 'oauth_app_name' => self::OAUTH_APP_NAME, ]; } @@ -78,7 +79,8 @@ class DigikeyProvider implements InfoProviderInterface public function isActive(): bool { - return true; + //The client ID has to be set and a token has to be available (user clicked connect) + return !empty($this->clientId) && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); } public function searchByKeyword(string $keyword): array diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index 61f79274..30821bad 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -38,8 +38,9 @@ interface InfoProviderInterface * - logo?: The logo of the provider (e.g. "digikey.png") * - url?: The url of the provider (e.g. "https://www.digikey.com") * - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it + * - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown * - * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string } + * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string } */ public function getProviderInfo(): array; diff --git a/src/Services/OAuth/OAuthTokenManager.php b/src/Services/OAuth/OAuthTokenManager.php index 1e76c8d0..bf4dcaa1 100644 --- a/src/Services/OAuth/OAuthTokenManager.php +++ b/src/Services/OAuth/OAuthTokenManager.php @@ -74,6 +74,16 @@ final class OAuthTokenManager return $this->entityManager->getRepository(OAuthToken::class)->findOneBy(['name' => $app_name]); } + /** + * Checks if a token for the given app name is existing + * @param string $app_name + * @return bool + */ + public function hasToken(string $app_name): bool + { + return $this->getToken($app_name) !== null; + } + /** * This function refreshes the token for the given app name. The new token is saved to the database * The app_name must be registered in the knpu_oauth2_client.yaml diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig index 4f8dc3b8..7304806a 100644 --- a/templates/info_providers/providers.macro.html.twig +++ b/templates/info_providers/providers.macro.html.twig @@ -30,6 +30,10 @@ {{ capability.translationKey|trans }} {% endfor %} + {% if provider.providerInfo.oauth_app_name is defined and provider.providerInfo.oauth_app_name is not empty %} +
+ {% trans %}oauth_client.connect.btn{% endtrans %} + {% endif %} {% if provider.active == false %} @@ -42,7 +46,6 @@ {% endif %} - {% endif %} diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index 91f5cc70..1556530b 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -11,6 +11,8 @@ {% block card_content %} + All info providers + {{ form(form) }} {% if results is not null %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 049480e5..9e92eb7f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11495,5 +11495,11 @@ Please note, that you can not impersonate a disabled user. If you try you will g Created by Information provider
+ + + oauth_client.connect.btn + Connect OAuth + + From 01d9109c4586ca5cba7cc4cd5a872f8617b0cb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 17:10:48 +0200 Subject: [PATCH 19/34] Improved digikey provider --- config/packages/knpu_oauth2_client.yaml | 9 +- .../Providers/DigikeyProvider.php | 86 +++++++++++++++++-- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index c59fe206..f06bca1b 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -13,6 +13,11 @@ knpu_oauth2_client: redirect_params: {name: 'ip_digikey_oauth'} provider_options: - urlAuthorize: 'https://sandbox-api.digikey.com/v1/oauth2/authorize' - urlAccessToken: 'https://sandbox-api.digikey.com/v1/oauth2/token' + urlAuthorize: 'https://api.digikey.com/v1/oauth2/authorize' + urlAccessToken: 'https://api.digikey.com/v1/oauth2/token' urlResourceOwnerDetails: '' + + # Sandbox + #urlAuthorize: 'https://sandbox-api.digikey.com/v1/oauth2/authorize' + #urlAccessToken: 'https://sandbox-api.digikey.com/v1/oauth2/token' + #urlResourceOwnerDetails: '' diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index f8e968e1..b8630972 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -24,7 +24,10 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\OAuth\OAuthTokenManager; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -34,18 +37,24 @@ class DigikeyProvider implements InfoProviderInterface private const OAUTH_APP_NAME = 'ip_digikey_oauth'; + //Sandbox:'https://sandbox-api.digikey.com'; (you need to change it in knpu/oauth2-client-bundle config too) + private const BASE_URI = 'https://api.digikey.com'; + + private const VENDOR_NAME = 'DigiKey'; + private readonly HttpClientInterface $digikeyClient; - public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, string $currency, private readonly string $clientId) + + public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, private readonly string $currency, private readonly string $clientId) { //Create the HTTP client with some default options $this->digikeyClient = $httpClient->withOptions([ - "base_uri" => 'https://sandbox-api.digikey.com', + "base_uri" => self::BASE_URI, "headers" => [ "X-DIGIKEY-Client-Id" => $clientId, "X-DIGIKEY-Locale-Site" => 'DE', "X-DIGIKEY-Locale-Language" => 'de', - "X-DIGIKEY-Locale-Currency" => $currency, + "X-DIGIKEY-Locale-Currency" => $this->currency, "X-DIGIKEY-Customer-Id" => 0, ] ]); @@ -112,13 +121,40 @@ class DigikeyProvider implements InfoProviderInterface mpn: $product['ManufacturerPartNumber'], preview_image_url: $product['PrimaryPhoto'] ?? null, manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']), - provider_url: 'https://digikey.com'.$product['ProductUrl'], + provider_url: $product['ProductUrl'], ); } return $result; } + public function getDetails(string $id): PartDetailDTO + { + $response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . $id, [ + 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) + ]); + + $product = $response->toArray(); + + $footprint = null; + $parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint); + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['DigiKeyPartNumber'], + name: $product['ManufacturerPartNumber'], + description: $product['DetailedDescription'] ?? $product['ProductDescription'], + manufacturer: $product['Manufacturer']['Value'] ?? null, + mpn: $product['ManufacturerPartNumber'], + preview_image_url: $product['PrimaryPhoto'] ?? null, + manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']), + provider_url: $product['ProductUrl'], + footprint: $footprint, + parameters: $parameters, + vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']), + ); + } + /** * Converts the product status from the Digikey API to the manufacturing status used in Part-DB * @param string|null $dk_status @@ -138,8 +174,46 @@ class DigikeyProvider implements InfoProviderInterface }; } - public function getDetails(string $id): PartDetailDTO + /** + * This function converts the "Parameters" part of the Digikey API response to an array of ParameterDTOs + * @param array $parameters + * @param string|null $footprint_name You can pass a variable by reference, where the name of the footprint will be stored + * @return ParameterDTO[] + */ + private function parametersToDTOs(array $parameters, string|null &$footprint_name = null): array { - // TODO: Implement getDetails() method. + $results = []; + + $footprint_name = null; + + foreach ($parameters as $parameter) { + if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint" + $footprint_name = $parameter['Value']; + } + + $results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']); + } + + return $results; + } + + /** + * Converts the pricing (StandardPricing field) from the Digikey API to an array of PurchaseInfoDTOs + * @param array $price_breaks + * @param string $order_number + * @param string $product_url + * @return PurchaseInfoDTO[] + */ + private function pricingToDTOs(array $price_breaks, string $order_number, string $product_url): array + { + $prices = []; + + foreach ($price_breaks as $price_break) { + $prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->currency); + } + + return [ + new PurchaseInfoDTO(distributor_name: self::VENDOR_NAME, order_number: $order_number, prices: $prices, product_url: $product_url) + ]; } } \ No newline at end of file From 412fa3f0bf70384d6d3b22cb78920230f5b8c537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 18:35:44 +0200 Subject: [PATCH 20/34] Get datasheets and category from digikey --- .../Providers/DigikeyProvider.php | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index b8630972..b1196fc7 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; @@ -116,7 +117,8 @@ class DigikeyProvider implements InfoProviderInterface provider_key: $this->getProviderKey(), provider_id: $product['DigiKeyPartNumber'], name: $product['ManufacturerPartNumber'], - description: $product['ProductDescription'], + description: $product['DetailedDescription'] ?? $product['ProductDescription'], + category: $this->getCategoryString($product), manufacturer: $product['Manufacturer']['Value'] ?? null, mpn: $product['ManufacturerPartNumber'], preview_image_url: $product['PrimaryPhoto'] ?? null, @@ -138,18 +140,22 @@ class DigikeyProvider implements InfoProviderInterface $footprint = null; $parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint); + $media = $this->mediaToDTOs($product['MediaLinks']); return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $product['DigiKeyPartNumber'], name: $product['ManufacturerPartNumber'], description: $product['DetailedDescription'] ?? $product['ProductDescription'], + category: $this->getCategoryString($product), manufacturer: $product['Manufacturer']['Value'] ?? null, mpn: $product['ManufacturerPartNumber'], preview_image_url: $product['PrimaryPhoto'] ?? null, manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']), provider_url: $product['ProductUrl'], footprint: $footprint, + datasheets: $media['datasheets'], + images: $media['images'], parameters: $parameters, vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']), ); @@ -174,6 +180,17 @@ class DigikeyProvider implements InfoProviderInterface }; } + private function getCategoryString(array $product): string + { + $category = $product['Category']['Value']; + $sub_category = $product['Family']['Value']; + + //Replace the ' - ' category separator with ' -> ' + $sub_category = str_replace(' - ', ' -> ', $sub_category); + + return $category . ' -> ' . $sub_category; + } + /** * This function converts the "Parameters" part of the Digikey API response to an array of ParameterDTOs * @param array $parameters @@ -216,4 +233,34 @@ class DigikeyProvider implements InfoProviderInterface new PurchaseInfoDTO(distributor_name: self::VENDOR_NAME, order_number: $order_number, prices: $prices, product_url: $product_url) ]; } + + /** + * @param array $media_links + * @return FileDTO[][] + * @phpstan-return array + */ + private function mediaToDTOs(array $media_links): array + { + $datasheets = []; + $images = []; + + foreach ($media_links as $media_link) { + $file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']); + + switch ($media_link['MediaType']) { + case 'Datasheets': + $datasheets[] = $file; + break; + case 'Product Photos': + $images[] = $file; + break; + } + } + + return [ + 'datasheets' => $datasheets, + 'images' => $images, + ]; + } + } \ No newline at end of file From 6862d318f039cbf3859fbabbd83e24a763eec3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 19:05:26 +0200 Subject: [PATCH 21/34] Cache the DTO objects returned by the info providers This saves API requests --- config/packages/cache.yaml | 3 ++ .../InfoProviderSystem/PartInfoRetriever.php | 41 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 07ecf18a..6adea442 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -20,3 +20,6 @@ framework: tree.cache: adapter: cache.app tags: true + + info_provider.cache: + adapter: cache.app diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index a010fbcd..9b0c3d15 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -27,15 +27,22 @@ use App\Entity\Parts\Part; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; -class PartInfoRetriever +final class PartInfoRetriever { - public function __construct(private readonly ProviderRegistry $provider_registry, private readonly DTOtoEntityConverter $dto_to_entity_converter) + + private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days + private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 7; // 7 days + + public function __construct(private readonly ProviderRegistry $provider_registry, + private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache) { } /** - * Search for a keyword in the given providers + * Search for a keyword in the given providers. The results can be cached * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @param string $keyword The keyword to search for * @return SearchResultDTO[] The search results @@ -54,21 +61,43 @@ class PartInfoRetriever } /** @noinspection SlowArrayOperationsInLoopInspection */ - $results = array_merge($results, $provider->searchByKeyword($keyword)); + $results = array_merge($results, $this->searchInProvider($provider, $keyword)); } return $results; } /** - * Retrieves the details for a part from the given provider with the given (provider) part id + * Search for a keyword in the given provider. The result is cached for 7 days. + * @return SearchResultDTO[] + */ + protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array + { + return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$keyword}", function (ItemInterface $item) use ($provider, $keyword) { + //Set the expiration time + $item->expiresAfter(self::CACHE_RESULT_EXPIRATION); + + return $provider->searchByKeyword($keyword); + }); + } + + /** + * Retrieves the details for a part from the given provider with the given (provider) part id. + * The result is cached for 4 days. * @param string $provider_key * @param string $part_id * @return */ public function getDetails(string $provider_key, string $part_id): PartDetailDTO { - return $this->provider_registry->getProviderByKey($provider_key)->getDetails($part_id); + $provider = $this->provider_registry->getProviderByKey($provider_key); + + return $this->partInfoCache->get("details_{$provider_key}_{$part_id}", function (ItemInterface $item) use ($provider, $part_id) { + //Set the expiration time + $item->expiresAfter(self::CACHE_DETAIL_EXPIRATION); + + return $provider->getDetails($part_id); + }); } public function getDetailsForSearchResult(SearchResultDTO $search_result): PartDetailDTO From 97ab1f04923c63c87c5cb0ec24ff35092cf0117e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 20:05:11 +0200 Subject: [PATCH 22/34] Improved search page --- assets/js/register_events.js | 2 +- .../InfoProviderSystem/PartSearchType.php | 16 +++- .../search/part_search.html.twig | 87 ++++++++++++++----- translations/messages.en.xlf | 38 +++++++- 4 files changed, 117 insertions(+), 26 deletions(-) diff --git a/assets/js/register_events.js b/assets/js/register_events.js index 383cf7bd..22e91fdf 100644 --- a/assets/js/register_events.js +++ b/assets/js/register_events.js @@ -62,7 +62,7 @@ class RegisterEventHelper { const handler = () => { $(".tooltip").remove(); //Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.) - $('a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title]') + $('a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title]') //@ts-ignore .tooltip("hide").tooltip({container: "body", placement: "auto", boundary: 'window'}); }; diff --git a/src/Form/InfoProviderSystem/PartSearchType.php b/src/Form/InfoProviderSystem/PartSearchType.php index 5bbbf156..9d582ca4 100644 --- a/src/Form/InfoProviderSystem/PartSearchType.php +++ b/src/Form/InfoProviderSystem/PartSearchType.php @@ -30,10 +30,18 @@ use Symfony\Component\Form\FormBuilderInterface; class PartSearchType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('keyword', SearchType::class); - $builder->add('providers', ProviderSelectType::class); - $builder->add('submit', SubmitType::class); + $builder->add('keyword', SearchType::class, [ + 'label' => 'info_providers.search.keyword', + ]); + $builder->add('providers', ProviderSelectType::class, [ + 'label' => 'info_providers.search.providers', + 'help' => 'info_providers.search.providers.help', + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.search.submit' + ]); } } \ No newline at end of file diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index 1556530b..a3476575 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -11,36 +11,83 @@ {% block card_content %} - All info providers - {{ form(form) }} + + {{ form_start(form) }} + + {{ form_row(form.keyword) }} + {{ form_row(form.providers) }} + + + + {{ form_row(form.submit) }} + + {{ form_end(form) }} {% if results is not null %} - - - - - - - - - - + + + + + + + + + {% for result in results %} - - - - - - - + + + + + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 9e92eb7f..8312871e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11438,7 +11438,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g info_providers.providers_list.title - Info providers + Create parts from info provider @@ -11501,5 +11501,41 @@ Please note, that you can not impersonate a disabled user. If you try you will g Connect OAuth + + + info_providers.table.provider.label + Provider + + + + + info_providers.search.keyword + Keyword + + + + + info_providers.search.submit + Search + + + + + info_providers.search.providers.help + Select the providers in which should be searched. + + + + + info_providers.search.providers + Providers + + + + + info_providers.search.info_providers_list + Show all available info providers + + From 7bbf612394f6dd0f51ebc80406c8e75676c56f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 20:09:20 +0200 Subject: [PATCH 23/34] Fixed title of info providers list --- .../providers_list/providers_list.html.twig | 2 +- templates/info_providers/search/part_search.html.twig | 4 ++-- translations/messages.en.xlf | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/templates/info_providers/providers_list/providers_list.html.twig b/templates/info_providers/providers_list/providers_list.html.twig index 18037bd2..71e85175 100644 --- a/templates/info_providers/providers_list/providers_list.html.twig +++ b/templates/info_providers/providers_list/providers_list.html.twig @@ -5,7 +5,7 @@ {% block title %}{% trans %}info_providers.providers_list.title{% endtrans %}{% endblock %} {% block card_title %} - {% trans %}info_providers.providers_list.title{% endtrans %} + {% trans %}info_providers.providers_list.title{% endtrans %} {% endblock %} {% block card_content %} diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index a3476575..c28235c7 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -3,10 +3,10 @@ {% import "info_providers/providers.macro.html.twig" as providers_macro %} {% import "helper.twig" as helper %} -{% block title %}{% trans %}info_providers.providers_list.title{% endtrans %}{% endblock %} +{% block title %}{% trans %}info_providers.search.title{% endtrans %}{% endblock %} {% block card_title %} - {% trans %}info_providers.providers_list.title{% endtrans %} + {% trans %}info_providers.search.title{% endtrans %} {% endblock %} {% block card_content %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 8312871e..e252abf8 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11438,7 +11438,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g info_providers.providers_list.title - Create parts from info provider + Information providers @@ -11537,5 +11537,11 @@ Please note, that you can not impersonate a disabled user. If you try you will g Show all available info providers + + + info_providers.search.title + Create parts from info provider + + From b3b205cd6eaea0fa9e1dd0aa957257d8d47240d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 20:33:24 +0200 Subject: [PATCH 24/34] Added permissions to control access to info providers and oauth tokens --- config/permissions.yaml | 9 +++++++++ src/Controller/InfoProviderController.php | 5 +++++ src/Controller/OAuthClientController.php | 4 ++++ src/Entity/UserSystem/PermissionData.php | 2 +- src/Services/Trees/ToolsTreeBuilder.php | 7 +++++++ .../UserSystem/PermissionPresetsHelper.php | 6 ++++++ .../UserSystem/PermissionSchemaUpdater.php | 9 +++++++++ .../UserSystem/PermissionSchemaUpdaterTest.php | 13 +++++++++++++ translations/messages.en.xlf | 18 ++++++++++++++++++ 9 files changed, 72 insertions(+), 1 deletion(-) diff --git a/config/permissions.yaml b/config/permissions.yaml index 6cb798f5..d00e1e77 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -139,6 +139,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co ic_logos: label: "perm.tools.ic_logos" + info_providers: + label: "perm.part.info_providers" + operations: + create_parts: + label: "perm.part.info_providers.create_parts" + alsoSet: ['parts.create'] + groups: label: "perm.groups" group: "system" @@ -242,6 +249,8 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co alsoSet: 'show_logs' server_infos: label: "perm.server_infos" + manage_oauth_tokens: + label: "Manage OAuth tokens" attachments: label: "perm.part.attachments" diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index dbcd6a2a..3828a74e 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -51,6 +51,8 @@ class InfoProviderController extends AbstractController #[Route('/providers', name: 'info_providers_list')] public function listProviders(): Response { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + return $this->render('info_providers/providers_list/providers_list.html.twig', [ 'active_providers' => $this->providerRegistry->getActiveProviders(), 'disabled_providers' => $this->providerRegistry->getDisabledProviders(), @@ -60,6 +62,8 @@ class InfoProviderController extends AbstractController #[Route('/search', name: 'info_providers_search')] public function search(Request $request): Response { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + $form = $this->createForm(PartSearchType::class); $form->handleRequest($request); @@ -82,6 +86,7 @@ class InfoProviderController extends AbstractController public function createPart(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, AttachmentSubmitHandler $attachmentSubmitHandler, string $providerKey, string $providerId): Response { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); $new_part = $this->infoRetriever->createPart($providerKey, $providerId); diff --git a/src/Controller/OAuthClientController.php b/src/Controller/OAuthClientController.php index 0b80a324..71d8ec1d 100644 --- a/src/Controller/OAuthClientController.php +++ b/src/Controller/OAuthClientController.php @@ -43,6 +43,8 @@ class OAuthClientController extends AbstractController #[Route('/{name}/connect', name: 'oauth_client_connect')] public function connect(string $name): Response { + $this->denyAccessUnlessGranted('@system.manage_oauth_tokens'); + return $this->clientRegistry ->getClient($name) // key used in config/packages/knpu_oauth2_client.yaml ->redirect(); @@ -51,6 +53,8 @@ class OAuthClientController extends AbstractController #[Route('/{name}/check', name: 'oauth_client_check')] public function check(string $name, Request $request): Response { + $this->denyAccessUnlessGranted('@system.manage_oauth_tokens'); + $client = $this->clientRegistry->getClient($name); $access_token = $client->getAccessToken(); diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index 01bb2416..38f4b774 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 2; + public const CURRENT_SCHEMA_VERSION = 3; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index d1c01063..b0fafb4f 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -133,6 +133,13 @@ class ToolsTreeBuilder ))->setIcon('fa-treeview fa-fw fa-solid fa-file-import'); } + if ($this->security->isGranted('@info_providers.create_parts')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('info_providers.search.title'), + $this->urlGenerator->generate('info_providers_search') + ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); + } + return $nodes; } diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index 15a29b13..ea2391f7 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -105,6 +105,9 @@ class PermissionPresetsHelper $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW); + //Allow to manage Oauth tokens + $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW); + } private function editor(HasPermissionsInterface $permHolder): HasPermissionsInterface @@ -139,6 +142,9 @@ class PermissionPresetsHelper //Various other permissions $this->permissionResolver->setPermission($permHolder, 'tools', 'lastActivity', PermissionData::ALLOW); + //Allow to create parts from information providers + $this->permissionResolver->setPermission($permHolder, 'info_providers', 'create_parts', PermissionData::ALLOW); + return $permHolder; } diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index 5fb08182..e716bcc9 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -138,4 +138,13 @@ class PermissionSchemaUpdater $holder->getPermissions()->removePermission('devices'); } } + + private function upgradeSchemaToVersion3(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection + { + //If the info_providers permissions are not defined yet, set it if the user can create parts + if (!$holder->getPermissions()->isAnyOperationOfPermissionSet('info_providers')) { + $user_can_create_parts = $holder->getPermissions()->getPermissionValue('parts', 'create'); + $holder->getPermissions()->setPermissionValue('info_providers', 'create_parts', $user_can_create_parts); + } + } } diff --git a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php index 1acadd14..b1a0e150 100644 --- a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php +++ b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php @@ -110,4 +110,17 @@ class PermissionSchemaUpdaterTest extends WebTestCase self::assertEquals(PermissionData::INHERIT, $user->getPermissions()->getPermissionValue('projects', 'edit')); self::assertEquals(PermissionData::DISALLOW, $user->getPermissions()->getPermissionValue('projects', 'delete')); } + + public function testUpgradeSchemaToVersion3(): void + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(2); + $perm_data->setPermissionValue('parts', 'create', PermissionData::ALLOW); + $user = new TestPermissionHolder($perm_data); + + //After the upgrade the user should be allowed to create parts from info providers + self::assertTrue($this->service->upgradeSchema($user, 3)); + + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('info_providers', 'create_parts')); + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e252abf8..7415c546 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11543,5 +11543,23 @@ Please note, that you can not impersonate a disabled user. If you try you will g Create parts from info provider + + + oauth_client.flash.connection_successful + Connected to OAuth application successfully! + + + + + perm.part.info_providers + Info providers + + + + + perm.part.info_providers.create_parts + Create parts from info provider + + From edc54aaf91a59ac7470e2727603708c91d94ce9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 20:47:25 +0200 Subject: [PATCH 25/34] Added migrations for sqlite and new additional_names field --- migrations/Version20230715225205.php | 33 -- migrations/Version20230716184033.php | 351 ++++++++++++++++++ .../Base/AbstractStructuralDBElement.php | 6 + 3 files changed, 357 insertions(+), 33 deletions(-) delete mode 100644 migrations/Version20230715225205.php create mode 100644 migrations/Version20230716184033.php diff --git a/migrations/Version20230715225205.php b/migrations/Version20230715225205.php deleted file mode 100644 index 03cdc930..00000000 --- a/migrations/Version20230715225205.php +++ /dev/null @@ -1,33 +0,0 @@ -addSql('CREATE TABLE oauth_tokens (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(255) DEFAULT NULL, expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', refresh_token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX oauth_tokens_unique_name (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql('ALTER TABLE parts ADD provider_reference_provider_key VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_id VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_url VARCHAR(255) DEFAULT NULL, ADD provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('DROP TABLE oauth_tokens'); - $this->addSql('ALTER TABLE `parts` DROP provider_reference_provider_key, DROP provider_reference_provider_id, DROP provider_reference_provider_url, DROP provider_reference_last_updated'); - } -} diff --git a/migrations/Version20230716184033.php b/migrations/Version20230716184033.php new file mode 100644 index 00000000..16e3e5b9 --- /dev/null +++ b/migrations/Version20230716184033.php @@ -0,0 +1,351 @@ +addSql('CREATE TABLE oauth_tokens (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(255) DEFAULT NULL, expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', refresh_token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX oauth_tokens_unique_name (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE attachment_types ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE categories ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE currencies ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE footprints ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE groups ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE manufacturers ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE measurement_units ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE parts ADD provider_reference_provider_key VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_id VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_url VARCHAR(255) DEFAULT NULL, ADD provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + $this->addSql('ALTER TABLE projects ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE storelocations ADD alternative_names LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE suppliers ADD alternative_names LONGTEXT DEFAULT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE oauth_tokens'); + $this->addSql('ALTER TABLE `attachment_types` DROP alternative_names'); + $this->addSql('ALTER TABLE `categories` DROP alternative_names'); + $this->addSql('ALTER TABLE currencies DROP alternative_names'); + $this->addSql('ALTER TABLE `footprints` DROP alternative_names'); + $this->addSql('ALTER TABLE `groups` DROP alternative_names'); + $this->addSql('ALTER TABLE `manufacturers` DROP alternative_names'); + $this->addSql('ALTER TABLE `measurement_units` DROP alternative_names'); + $this->addSql('ALTER TABLE `parts` DROP provider_reference_provider_key, DROP provider_reference_provider_id, DROP provider_reference_provider_url, DROP provider_reference_last_updated'); + $this->addSql('ALTER TABLE projects DROP alternative_names'); + $this->addSql('ALTER TABLE `storelocations` DROP alternative_names'); + $this->addSql('ALTER TABLE `suppliers` DROP alternative_names'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('CREATE TABLE oauth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, token VARCHAR(255) DEFAULT NULL, expires_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable) + , refresh_token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL)'); + $this->addSql('CREATE UNIQUE INDEX oauth_tokens_unique_name ON oauth_tokens (name)'); + $this->addSql('ALTER TABLE attachment_types ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('ALTER TABLE categories ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies'); + $this->addSql('DROP TABLE currencies'); + $this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, alternative_names CLOB DEFAULT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C44693EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies'); + $this->addSql('DROP TABLE __temp__currencies'); + $this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)'); + $this->addSql('CREATE INDEX currency_idx_name ON currencies (name)'); + $this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)'); + $this->addSql('CREATE INDEX IDX_37C44693EA7100A1 ON currencies (id_preview_attachment)'); + $this->addSql('ALTER TABLE footprints ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM groups'); + $this->addSql('DROP TABLE groups'); + $this->addSql('CREATE TABLE groups (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB NOT NULL --(DC2Type:json) + , alternative_names CLOB DEFAULT NULL, CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D3970EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO groups (id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups'); + $this->addSql('DROP TABLE __temp__groups'); + $this->addSql('CREATE INDEX group_idx_parent_name ON groups (parent_id, name)'); + $this->addSql('CREATE INDEX group_idx_name ON groups (name)'); + $this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON groups (parent_id)'); + $this->addSql('CREATE INDEX IDX_F06D3970EA7100A1 ON groups (id_preview_attachment)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM log'); + $this->addSql('DROP TABLE log'); + $this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, datetime DATETIME NOT NULL, level TINYINT NOT NULL --(DC2Type:tinyint) + , target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL --(DC2Type:json) + , type SMALLINT NOT NULL, username VARCHAR(255) NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO log (id, id_user, datetime, level, target_id, target_type, extra, type, username) SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM __temp__log'); + $this->addSql('DROP TABLE __temp__log'); + $this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)'); + $this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)'); + $this->addSql('CREATE INDEX log_idx_type ON log (type)'); + $this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)'); + $this->addSql('ALTER TABLE manufacturers ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('ALTER TABLE measurement_units ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM parts'); + $this->addSql('DROP TABLE parts'); + $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(255) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)'); + $this->addSql('CREATE INDEX parts_idx_name ON parts (name)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM pricedetails'); + $this->addSql('DROP TABLE pricedetails'); + $this->addSql('CREATE TABLE pricedetails (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL --(DC2Type:big_decimal) + , price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO pricedetails (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails'); + $this->addSql('DROP TABLE __temp__pricedetails'); + $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON pricedetails (orderdetails_id)'); + $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON pricedetails (id_currency)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON pricedetails (min_discount_quantity)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON pricedetails (min_discount_quantity, price_related_quantity)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added FROM project_bom_entries'); + $this->addSql('DROP TABLE project_bom_entries'); + $this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, price_currency_id INTEGER DEFAULT NULL, quantity DOUBLE PRECISION NOT NULL, mountnames CLOB NOT NULL, name VARCHAR(255) DEFAULT NULL, comment CLOB NOT NULL, price NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AFC547992F180363 FOREIGN KEY (id_device) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AFC54799C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added) SELECT id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added FROM __temp__project_bom_entries'); + $this->addSql('DROP TABLE __temp__project_bom_entries'); + $this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)'); + $this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)'); + $this->addSql('ALTER TABLE projects ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('ALTER TABLE storelocations ADD COLUMN alternative_names CLOB DEFAULT NULL'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM suppliers'); + $this->addSql('DROP TABLE suppliers'); + $this->addSql('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, alternative_names CLOB DEFAULT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES suppliers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO suppliers (id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON suppliers (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON suppliers (parent_id)'); + $this->addSql('CREATE INDEX supplier_idx_name ON suppliers (name)'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON suppliers (parent_id, name)'); + $this->addSql('CREATE INDEX IDX_AC28B95CEA7100A1 ON suppliers (id_preview_attachment)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data, saml_user, about_me, show_email_on_profile FROM users'); + $this->addSql('DROP TABLE users'); + $this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL --(DC2Type:json) + , google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL --(DC2Type:json) + , backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB NOT NULL --(DC2Type:json) + , saml_user BOOLEAN NOT NULL, about_me CLOB NOT NULL, show_email_on_profile BOOLEAN DEFAULT 0 NOT NULL, CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E9EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO users (id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data, saml_user, about_me, show_email_on_profile) SELECT id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data, saml_user, about_me, show_email_on_profile FROM __temp__users'); + $this->addSql('DROP TABLE __temp__users'); + $this->addSql('CREATE INDEX user_idx_username ON users (name)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON users (name)'); + $this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON users (group_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E938248176 ON users (currency_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E9EA7100A1 ON users (id_preview_attachment)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added, other_ui FROM webauthn_keys'); + $this->addSql('DROP TABLE webauthn_keys'); + $this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL --(DC2Type:base64) + , type VARCHAR(255) NOT NULL, transports CLOB NOT NULL --(DC2Type:array) + , attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL --(DC2Type:trust_path) + , aaguid CLOB NOT NULL --(DC2Type:aaguid) + , credential_public_key CLOB NOT NULL --(DC2Type:base64) + , user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, other_ui CLOB DEFAULT NULL --(DC2Type:array) + , CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added, other_ui) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added, other_ui FROM __temp__webauthn_keys'); + $this->addSql('DROP TABLE __temp__webauthn_keys'); + $this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('DROP TABLE oauth_tokens'); + $this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, parent_id, id_preview_attachment, filetype_filter, comment, not_selectable, name, last_modified, datetime_added FROM "attachment_types"'); + $this->addSql('DROP TABLE "attachment_types"'); + $this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, filetype_filter CLOB NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "attachment_types" (id, parent_id, id_preview_attachment, filetype_filter, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, filetype_filter, comment, not_selectable, name, last_modified, datetime_added FROM __temp__attachment_types'); + $this->addSql('DROP TABLE __temp__attachment_types'); + $this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)'); + $this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)'); + $this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)'); + $this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__categories AS SELECT id, parent_id, id_preview_attachment, partname_hint, partname_regex, disable_footprints, disable_manufacturers, disable_autodatasheets, disable_properties, default_description, default_comment, comment, not_selectable, name, last_modified, datetime_added FROM "categories"'); + $this->addSql('DROP TABLE "categories"'); + $this->addSql('CREATE TABLE "categories" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, partname_hint CLOB NOT NULL, partname_regex CLOB NOT NULL, disable_footprints BOOLEAN NOT NULL, disable_manufacturers BOOLEAN NOT NULL, disable_autodatasheets BOOLEAN NOT NULL, disable_properties BOOLEAN NOT NULL, default_description CLOB NOT NULL, default_comment CLOB NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "categories" (id, parent_id, id_preview_attachment, partname_hint, partname_regex, disable_footprints, disable_manufacturers, disable_autodatasheets, disable_properties, default_description, default_comment, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, partname_hint, partname_regex, disable_footprints, disable_manufacturers, disable_autodatasheets, disable_properties, default_description, default_comment, comment, not_selectable, name, last_modified, datetime_added FROM __temp__categories'); + $this->addSql('DROP TABLE __temp__categories'); + $this->addSql('CREATE INDEX IDX_3AF34668727ACA70 ON "categories" (parent_id)'); + $this->addSql('CREATE INDEX IDX_3AF34668EA7100A1 ON "categories" (id_preview_attachment)'); + $this->addSql('CREATE INDEX category_idx_name ON "categories" (name)'); + $this->addSql('CREATE INDEX category_idx_parent_name ON "categories" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies'); + $this->addSql('DROP TABLE currencies'); + $this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL -- +(DC2Type:big_decimal) + , iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C44693EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies'); + $this->addSql('DROP TABLE __temp__currencies'); + $this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)'); + $this->addSql('CREATE INDEX IDX_37C44693EA7100A1 ON currencies (id_preview_attachment)'); + $this->addSql('CREATE INDEX currency_idx_name ON currencies (name)'); + $this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__footprints AS SELECT id, parent_id, id_footprint_3d, id_preview_attachment, comment, not_selectable, name, last_modified, datetime_added FROM "footprints"'); + $this->addSql('DROP TABLE "footprints"'); + $this->addSql('CREATE TABLE "footprints" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_footprint_3d INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_A34D68A2727ACA70 FOREIGN KEY (parent_id) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_A34D68A232A38C34 FOREIGN KEY (id_footprint_3d) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_A34D68A2EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "footprints" (id, parent_id, id_footprint_3d, id_preview_attachment, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_footprint_3d, id_preview_attachment, comment, not_selectable, name, last_modified, datetime_added FROM __temp__footprints'); + $this->addSql('DROP TABLE __temp__footprints'); + $this->addSql('CREATE INDEX IDX_A34D68A2727ACA70 ON "footprints" (parent_id)'); + $this->addSql('CREATE INDEX IDX_A34D68A232A38C34 ON "footprints" (id_footprint_3d)'); + $this->addSql('CREATE INDEX IDX_A34D68A2EA7100A1 ON "footprints" (id_preview_attachment)'); + $this->addSql('CREATE INDEX footprint_idx_name ON "footprints" (name)'); + $this->addSql('CREATE INDEX footprint_idx_parent_name ON "footprints" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM "groups"'); + $this->addSql('DROP TABLE "groups"'); + $this->addSql('CREATE TABLE "groups" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB NOT NULL -- +(DC2Type:json) + , CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D3970EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "groups" (id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachment, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups'); + $this->addSql('DROP TABLE __temp__groups'); + $this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON "groups" (parent_id)'); + $this->addSql('CREATE INDEX IDX_F06D3970EA7100A1 ON "groups" (id_preview_attachment)'); + $this->addSql('CREATE INDEX group_idx_name ON "groups" (name)'); + $this->addSql('CREATE INDEX group_idx_parent_name ON "groups" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM log'); + $this->addSql('DROP TABLE log'); + $this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, username VARCHAR(255) NOT NULL, datetime DATETIME NOT NULL, level TINYINT NOT NULL -- +(DC2Type:tinyint) + , target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL -- +(DC2Type:json) + , type SMALLINT NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO log (id, id_user, username, datetime, level, target_id, target_type, extra, type) SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM __temp__log'); + $this->addSql('DROP TABLE __temp__log'); + $this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)'); + $this->addSql('CREATE INDEX log_idx_type ON log (type)'); + $this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)'); + $this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__manufacturers AS SELECT id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM "manufacturers"'); + $this->addSql('DROP TABLE "manufacturers"'); + $this->addSql('CREATE TABLE "manufacturers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_94565B12727ACA70 FOREIGN KEY (parent_id) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_94565B12EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "manufacturers" (id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__manufacturers'); + $this->addSql('DROP TABLE __temp__manufacturers'); + $this->addSql('CREATE INDEX IDX_94565B12727ACA70 ON "manufacturers" (parent_id)'); + $this->addSql('CREATE INDEX IDX_94565B12EA7100A1 ON "manufacturers" (id_preview_attachment)'); + $this->addSql('CREATE INDEX manufacturer_name ON "manufacturers" (name)'); + $this->addSql('CREATE INDEX manufacturer_idx_parent_name ON "manufacturers" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__measurement_units AS SELECT id, parent_id, id_preview_attachment, unit, is_integer, use_si_prefix, comment, not_selectable, name, last_modified, datetime_added FROM "measurement_units"'); + $this->addSql('DROP TABLE "measurement_units"'); + $this->addSql('CREATE TABLE "measurement_units" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, unit VARCHAR(255) DEFAULT NULL, is_integer BOOLEAN NOT NULL, use_si_prefix BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_F5AF83CF727ACA70 FOREIGN KEY (parent_id) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F5AF83CFEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "measurement_units" (id, parent_id, id_preview_attachment, unit, is_integer, use_si_prefix, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, unit, is_integer, use_si_prefix, comment, not_selectable, name, last_modified, datetime_added FROM __temp__measurement_units'); + $this->addSql('DROP TABLE __temp__measurement_units'); + $this->addSql('CREATE INDEX IDX_F5AF83CF727ACA70 ON "measurement_units" (parent_id)'); + $this->addSql('CREATE INDEX IDX_F5AF83CFEA7100A1 ON "measurement_units" (id_preview_attachment)'); + $this->addSql('CREATE INDEX unit_idx_name ON "measurement_units" (name)'); + $this->addSql('CREATE INDEX unit_idx_parent_name ON "measurement_units" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM "parts"'); + $this->addSql('DROP TABLE "parts"'); + $this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "parts" (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM "pricedetails"'); + $this->addSql('DROP TABLE "pricedetails"'); + $this->addSql('CREATE TABLE "pricedetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL -- +(DC2Type:big_decimal) + , price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES "orderdetails" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "pricedetails" (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails'); + $this->addSql('DROP TABLE __temp__pricedetails'); + $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON "pricedetails" (id_currency)'); + $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON "pricedetails" (orderdetails_id)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON "pricedetails" (min_discount_quantity)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON "pricedetails" (min_discount_quantity, price_related_quantity)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added FROM project_bom_entries'); + $this->addSql('DROP TABLE project_bom_entries'); + $this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, price_currency_id INTEGER DEFAULT NULL, quantity DOUBLE PRECISION NOT NULL, mountnames CLOB NOT NULL, name VARCHAR(255) DEFAULT NULL, comment CLOB NOT NULL, price NUMERIC(11, 5) DEFAULT NULL -- +(DC2Type:big_decimal) + , last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_1AA2DD312F180363 FOREIGN KEY (id_device) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD31C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added) SELECT id, id_device, id_part, price_currency_id, quantity, mountnames, name, comment, price, last_modified, datetime_added FROM __temp__project_bom_entries'); + $this->addSql('DROP TABLE __temp__project_bom_entries'); + $this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)'); + $this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__projects AS SELECT id, parent_id, id_preview_attachment, order_quantity, status, order_only_missing_parts, description, comment, not_selectable, name, last_modified, datetime_added FROM projects'); + $this->addSql('DROP TABLE projects'); + $this->addSql('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, order_quantity INTEGER NOT NULL, status VARCHAR(64) DEFAULT NULL, order_only_missing_parts BOOLEAN NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_5C93B3A4EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO projects (id, parent_id, id_preview_attachment, order_quantity, status, order_only_missing_parts, description, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachment, order_quantity, status, order_only_missing_parts, description, comment, not_selectable, name, last_modified, datetime_added FROM __temp__projects'); + $this->addSql('DROP TABLE __temp__projects'); + $this->addSql('CREATE INDEX IDX_5C93B3A4727ACA70 ON projects (parent_id)'); + $this->addSql('CREATE INDEX IDX_5C93B3A4EA7100A1 ON projects (id_preview_attachment)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__storelocations AS SELECT id, parent_id, storage_type_id, id_owner, id_preview_attachment, is_full, only_single_part, limit_to_existing_parts, part_owner_must_match, comment, not_selectable, name, last_modified, datetime_added FROM "storelocations"'); + $this->addSql('DROP TABLE "storelocations"'); + $this->addSql('CREATE TABLE "storelocations" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, storage_type_id INTEGER DEFAULT NULL, id_owner INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, is_full BOOLEAN NOT NULL, only_single_part BOOLEAN NOT NULL, limit_to_existing_parts BOOLEAN NOT NULL, part_owner_must_match BOOLEAN DEFAULT 0 NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_7517020727ACA70 FOREIGN KEY (parent_id) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_7517020B270BFF1 FOREIGN KEY (storage_type_id) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_751702021E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_7517020EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "storelocations" (id, parent_id, storage_type_id, id_owner, id_preview_attachment, is_full, only_single_part, limit_to_existing_parts, part_owner_must_match, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, storage_type_id, id_owner, id_preview_attachment, is_full, only_single_part, limit_to_existing_parts, part_owner_must_match, comment, not_selectable, name, last_modified, datetime_added FROM __temp__storelocations'); + $this->addSql('DROP TABLE __temp__storelocations'); + $this->addSql('CREATE INDEX IDX_7517020727ACA70 ON "storelocations" (parent_id)'); + $this->addSql('CREATE INDEX IDX_7517020B270BFF1 ON "storelocations" (storage_type_id)'); + $this->addSql('CREATE INDEX IDX_751702021E5A74C ON "storelocations" (id_owner)'); + $this->addSql('CREATE INDEX IDX_7517020EA7100A1 ON "storelocations" (id_preview_attachment)'); + $this->addSql('CREATE INDEX location_idx_name ON "storelocations" (name)'); + $this->addSql('CREATE INDEX location_idx_parent_name ON "storelocations" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM "suppliers"'); + $this->addSql('DROP TABLE "suppliers"'); + $this->addSql('CREATE TABLE "suppliers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL -- +(DC2Type:big_decimal) + , address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "suppliers" (id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON "suppliers" (parent_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON "suppliers" (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CEA7100A1 ON "suppliers" (id_preview_attachment)'); + $this->addSql('CREATE INDEX supplier_idx_name ON "suppliers" (name)'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON "suppliers" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, about_me, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, show_email_on_profile, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, saml_user, last_modified, datetime_added, permissions_data FROM "users"'); + $this->addSql('DROP TABLE "users"'); + $this->addSql('CREATE TABLE "users" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, about_me CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL -- +(DC2Type:json) + , google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, show_email_on_profile BOOLEAN DEFAULT 0 NOT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL -- +(DC2Type:json) + , backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, saml_user BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB NOT NULL -- +(DC2Type:json) + , CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E9EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "users" (id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, about_me, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, show_email_on_profile, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, saml_user, last_modified, datetime_added, permissions_data) SELECT id, group_id, currency_id, id_preview_attachment, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, about_me, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, show_email_on_profile, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, saml_user, last_modified, datetime_added, permissions_data FROM __temp__users'); + $this->addSql('DROP TABLE __temp__users'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON "users" (name)'); + $this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON "users" (group_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E938248176 ON "users" (currency_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E9EA7100A1 ON "users" (id_preview_attachment)'); + $this->addSql('CREATE INDEX user_idx_username ON "users" (name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, other_ui, name, last_modified, datetime_added FROM webauthn_keys'); + $this->addSql('DROP TABLE webauthn_keys'); + $this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL -- +(DC2Type:base64) + , type VARCHAR(255) NOT NULL, transports CLOB NOT NULL -- +(DC2Type:array) + , attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL -- +(DC2Type:trust_path) + , aaguid CLOB NOT NULL -- +(DC2Type:aaguid) + , credential_public_key CLOB NOT NULL -- +(DC2Type:base64) + , user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, other_ui CLOB DEFAULT NULL -- +(DC2Type:array) + , name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, other_ui, name, last_modified, datetime_added) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, other_ui, name, last_modified, datetime_added FROM __temp__webauthn_keys'); + $this->addSql('DROP TABLE __temp__webauthn_keys'); + $this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)'); + } +} diff --git a/src/Entity/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php index eee8ebcc..ac201c0d 100644 --- a/src/Entity/Base/AbstractStructuralDBElement.php +++ b/src/Entity/Base/AbstractStructuralDBElement.php @@ -124,6 +124,12 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement */ private array $full_path_strings = []; + /** + * Alternative names (semicolon-separated) for this element, which can be used for searching (especially for info provider system) + */ + #[ORM\Column(type: Types::TEXT, nullable: true, options: ['default' => null])] + private ?string $alternative_names = ""; + public function __construct() { parent::__construct(); From b74ab18a6d8a6b5dc71a224fa91cfbc493fa0736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 22:59:46 +0200 Subject: [PATCH 26/34] Added possibility to define alternative names on data structures This can be used to find elements, based on the data returned by info providers --- .../Base/AbstractStructuralDBElement.php | 30 +++++++++++++++++++ src/Form/AdminPages/BaseEntityAdminForm.php | 16 ++++++++++ .../StructuralDBElementRepository.php | 13 ++++---- .../DTOtoEntityConverter.php | 2 ++ templates/admin/base_company_admin.html.twig | 1 + templates/admin/category_admin.html.twig | 2 ++ templates/admin/footprint_admin.html.twig | 4 +++ .../admin/measurement_unit_admin.html.twig | 1 + templates/admin/storelocation_admin.html.twig | 2 ++ templates/admin/supplier_admin.html.twig | 1 + translations/messages.en.xlf | 12 ++++++++ 11 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/Entity/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php index ac201c0d..5c4103d8 100644 --- a/src/Entity/Base/AbstractStructuralDBElement.php +++ b/src/Entity/Base/AbstractStructuralDBElement.php @@ -419,4 +419,34 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement return $this; } + + /** + * Returns a comma separated list of alternative names. + * @return string|null + */ + public function getAlternativeNames(): ?string + { + if ($this->alternative_names === null) { + return null; + } + + //Remove trailing comma + return rtrim($this->alternative_names, ','); + } + + /** + * Sets a comma separated list of alternative names. + * @return $this + */ + public function setAlternativeNames(?string $new_value): self + { + //Add a trailing comma, if not already there (makes it easier to find in the database) + if (is_string($new_value) && substr($new_value, -1) !== ',') { + $new_value .= ','; + } + + $this->alternative_names = $new_value; + + return $this; + } } diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index 268ea630..19af4de8 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -22,6 +22,9 @@ declare(strict_types=1); namespace App\Form\AdminPages; +use App\Entity\PriceInformations\Currency; +use App\Entity\ProjectSystem\Project; +use App\Entity\UserSystem\Group; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractStructuralDBElement; @@ -111,6 +114,19 @@ class BaseEntityAdminForm extends AbstractType ); } + if ($entity instanceof AbstractStructuralDBElement && !($entity instanceof Group || $entity instanceof Project || $entity instanceof Currency)) { + $builder->add('alternative_names', TextType::class, [ + 'required' => false, + 'label' => 'entity.edit.alternative_names.label', + 'help' => 'entity.edit.alternative_names.help', + 'empty_data' => null, + 'attr' => [ + 'class' => 'tagsinput', + 'data-controller' => 'elements--tagsinput', + ] + ]); + } + $this->additionalFormElements($builder, $options, $entity); //Attachment section diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index 529cce79..24087cfa 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -209,17 +209,17 @@ class StructuralDBElementRepository extends NamedDBElementRepository return $result[0]; } - /*//If we have no result, try to find the element by additional names + //If we have no result, try to find the element by alternative names $qb = $this->createQueryBuilder('e'); //Use lowercase conversion to be case-insensitive - $qb->where($qb->expr()->like('LOWER(e.additional_names)', 'LOWER(:name)')); - $qb->setParameter('name', '%'.$name.'%'); + $qb->where($qb->expr()->like('LOWER(e.alternative_names)', 'LOWER(:name)')); + $qb->setParameter('name', '%'.$name.',%'); $result = $qb->getQuery()->getResult(); - if (count($result) === 1) { + if (count($result) >= 1) { return $result[0]; - }*/ + } //If we find nothing, return null return null; @@ -247,6 +247,9 @@ class StructuralDBElementRepository extends NamedDBElementRepository $entity = new $class; $entity->setName($name); + //Set the found name to the alternative names, so the entity can be easily renamed later + $entity->setAlternativeNames($name); + $this->setNewEntityToCache($entity); } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 0c7a639d..a12628ac 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -265,6 +265,7 @@ final class DTOtoEntityConverter //If the entity was newly created, set the file filter if ($tmp->getId() === null) { $tmp->setFiletypeFilter('application/pdf'); + $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); } return $tmp; @@ -282,6 +283,7 @@ final class DTOtoEntityConverter //If the entity was newly created, set the file filter if ($tmp->getId() === null) { $tmp->setFiletypeFilter('image/*'); + $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); } return $tmp; diff --git a/templates/admin/base_company_admin.html.twig b/templates/admin/base_company_admin.html.twig index 5b5a72c5..2da5e02a 100644 --- a/templates/admin/base_company_admin.html.twig +++ b/templates/admin/base_company_admin.html.twig @@ -17,6 +17,7 @@ {% block additional_panes %}
+ {{ form_row(form.alternative_names) }} {{ form_row(form.comment) }}
{% endblock %} diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index c1542206..4ba0248f 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -26,6 +26,8 @@
+ {{ form_row(form.alternative_names) }} +
{{ form_row(form.partname_regex) }} {{ form_row(form.partname_hint) }}
diff --git a/templates/admin/footprint_admin.html.twig b/templates/admin/footprint_admin.html.twig index 04acaa39..e4ed7713 100644 --- a/templates/admin/footprint_admin.html.twig +++ b/templates/admin/footprint_admin.html.twig @@ -15,4 +15,8 @@ {% block new_title %} {% trans %}footprint.new{% endtrans %} +{% endblock %} + +{% block additional_controls %} + {{ form_row(form.alternative_names) }} {% endblock %} \ No newline at end of file diff --git a/templates/admin/measurement_unit_admin.html.twig b/templates/admin/measurement_unit_admin.html.twig index f498fb38..31748509 100644 --- a/templates/admin/measurement_unit_admin.html.twig +++ b/templates/admin/measurement_unit_admin.html.twig @@ -16,5 +16,6 @@ {{ form_row(form.unit) }} {{ form_row(form.is_integer) }} {{ form_row(form.use_si_prefix)}} + {{ form_row(form.alternative_names) }} {% endblock %} diff --git a/templates/admin/storelocation_admin.html.twig b/templates/admin/storelocation_admin.html.twig index 4741c02d..c93339dc 100644 --- a/templates/admin/storelocation_admin.html.twig +++ b/templates/admin/storelocation_admin.html.twig @@ -21,6 +21,8 @@ {% block additional_panes %}
+ {{ form_row(form.alternative_names) }} + {{ form_row(form.storage_type) }} {{ form_row(form.is_full) }} {{ form_row(form.limit_to_existing_parts) }} diff --git a/templates/admin/supplier_admin.html.twig b/templates/admin/supplier_admin.html.twig index 9c0fcb47..ce38a5ca 100644 --- a/templates/admin/supplier_admin.html.twig +++ b/templates/admin/supplier_admin.html.twig @@ -6,6 +6,7 @@ {% block additional_panes %}
+ {{ form_row(form.alternative_names) }} {{ form_row(form.default_currency) }} {{ form_row(form.shipping_costs) }} {{ form_row(form.comment) }} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 7415c546..20680fb7 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11561,5 +11561,17 @@ Please note, that you can not impersonate a disabled user. If you try you will g Create parts from info provider + + + entity.edit.alternative_names.label + Alternative names + + + + + entity.edit.alternative_names.help + The alternative names given here, are used to find this element based on the results of the information providers. + + From c810b6772c3d1dd05088f13ac8606b7e72b6e87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 23:19:02 +0200 Subject: [PATCH 27/34] Show the value returned by the provider on part creation page. This makes it easier to check or assign a element manually --- src/Controller/InfoProviderController.php | 7 +++++-- src/Form/Part/PartBaseType.php | 12 +++++++++++ src/Form/Type/StructuralEntityType.php | 21 +++++++++++++++++++ .../InfoProviderSystem/PartInfoRetriever.php | 17 +++++++++++++++ translations/messages.en.xlf | 6 ++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index 3828a74e..d7debe56 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -88,9 +88,12 @@ class InfoProviderController extends AbstractController { $this->denyAccessUnlessGranted('@info_providers.create_parts'); - $new_part = $this->infoRetriever->createPart($providerKey, $providerId); + $dto = $this->infoRetriever->getDetails($providerKey, $providerId); + $new_part = $this->infoRetriever->dtoToPart($dto); - $form = $this->createForm(PartBaseType::class, $new_part); + $form = $this->createForm(PartBaseType::class, $new_part, [ + 'info_provider_dto' => $dto, + ]); $form->handleRequest($request); diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 1fbaa55e..b15ec29f 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Form\Part; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\PartAttachment; use App\Entity\Parameters\PartParameter; @@ -51,6 +52,7 @@ use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class PartBaseType extends AbstractType { @@ -64,6 +66,8 @@ class PartBaseType extends AbstractType $part = $builder->getData(); $new_part = null === $part->getID(); + /** @var PartDetailDTO|null $dto */ + $dto = $options['info_provider_dto']; //Common section $builder @@ -95,6 +99,7 @@ class PartBaseType extends AbstractType ->add('category', StructuralEntityType::class, [ 'class' => Category::class, 'allow_add' => $this->security->isGranted('@categories.create'), + 'dto_value' => $dto?->category, 'label' => 'part.edit.category', 'disable_not_selectable' => true, ]) @@ -102,6 +107,7 @@ class PartBaseType extends AbstractType 'class' => Footprint::class, 'required' => false, 'label' => 'part.edit.footprint', + 'dto_value' => $dto?->footprint, 'allow_add' => $this->security->isGranted('@footprints.create'), 'disable_not_selectable' => true, ]) @@ -122,6 +128,7 @@ class PartBaseType extends AbstractType 'required' => false, 'label' => 'part.edit.manufacturer.label', 'allow_add' => $this->security->isGranted('@manufacturers.create'), + 'dto_value' => $dto?->manufacturer, 'disable_not_selectable' => true, ]) ->add('manufacturer_product_url', UrlType::class, [ @@ -268,10 +275,15 @@ class PartBaseType extends AbstractType ->add('reset', ResetType::class, ['label' => 'part.edit.reset']); } + + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Part::class, + 'info_provider_dto' => null, ]); + + $resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']); } } diff --git a/src/Form/Type/StructuralEntityType.php b/src/Form/Type/StructuralEntityType.php index fbc294e4..18368289 100644 --- a/src/Form/Type/StructuralEntityType.php +++ b/src/Form/Type/StructuralEntityType.php @@ -100,6 +100,17 @@ class StructuralEntityType extends AbstractType $resolver->setDefault('controller', 'elements--structural-entity-select'); + //Options for DTO values + $resolver->setDefault('dto_value', null); + $resolver->setAllowedTypes('dto_value', ['null', 'string']); + //If no help text is explicitly set, we use the dto value as help text and show it as html + $resolver->setDefault('help', function (Options $options) { + return $this->dtoText($options['dto_value']); + }); + $resolver->setDefault('help_html', function (Options $options) { + return $options['dto_value'] !== null; + }); + $resolver->setDefault('attr', function (Options $options) { $tmp = [ 'data-controller' => $options['controller'], @@ -114,6 +125,16 @@ class StructuralEntityType extends AbstractType }); } + private function dtoText(?string $text): ?string + { + if ($text === null) { + return null; + } + + $result = '' . $this->translator->trans('info_providers.form.help_prefix') . ': '; + + return $result . htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ; + } public function getParent(): string { diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 9b0c3d15..17f4acbc 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -100,11 +100,28 @@ final class PartInfoRetriever }); } + /** + * Retrieves the details for a part, based on the given search result. + * @param SearchResultDTO $search_result + * @return PartDetailDTO + */ public function getDetailsForSearchResult(SearchResultDTO $search_result): PartDetailDTO { return $this->getDetails($search_result->provider_key, $search_result->provider_id); } + /** + * Converts the given DTO to a part entity + * @return Part + */ + public function dtoToPart(PartDetailDTO $search_result): Part + { + return $this->createPart($search_result->provider_key, $search_result->provider_id); + } + + /** + * Use the given details to create a part entity + */ public function createPart(string $provider_key, string $part_id): Part { $details = $this->getDetails($provider_key, $part_id); diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 20680fb7..6a321966 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11573,5 +11573,11 @@ Please note, that you can not impersonate a disabled user. If you try you will g The alternative names given here, are used to find this element based on the results of the information providers. + + + info_providers.form.help_prefix + Provider + + From a5995a2ce81ef4ff68dd5b86038094f452dc8d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 23:46:20 +0200 Subject: [PATCH 28/34] Centralized logic for part creation form --- src/Controller/InfoProviderController.php | 73 +------------ src/Controller/PartController.php | 124 +++++++++++----------- src/Services/Parts/PartFormHelper.php | 108 +++++++++++++++++++ 3 files changed, 171 insertions(+), 134 deletions(-) create mode 100644 src/Services/Parts/PartFormHelper.php diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index d7debe56..cb95377b 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -30,6 +30,7 @@ use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\LogSystem\EventCommentHelper; +use App\Services\Parts\PartFormHelper; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; @@ -43,7 +44,7 @@ class InfoProviderController extends AbstractController { public function __construct(private readonly ProviderRegistry $providerRegistry, - private readonly PartInfoRetriever $infoRetriever, private readonly EventCommentHelper $commentHelper) + private readonly PartInfoRetriever $infoRetriever) { } @@ -81,74 +82,4 @@ class InfoProviderController extends AbstractController 'results' => $results, ]); } - - #[Route('/part/{providerKey}/{providerId}/create', name: 'info_providers_create_part')] - public function createPart(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, - AttachmentSubmitHandler $attachmentSubmitHandler, string $providerKey, string $providerId): Response - { - $this->denyAccessUnlessGranted('@info_providers.create_parts'); - - $dto = $this->infoRetriever->getDetails($providerKey, $providerId); - $new_part = $this->infoRetriever->dtoToPart($dto); - - $form = $this->createForm(PartBaseType::class, $new_part, [ - 'info_provider_dto' => $dto, - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - //Upload passed files - $attachments = $form['attachments']; - foreach ($attachments as $attachment) { - /** @var FormInterface $attachment */ - $options = [ - 'secure_attachment' => $attachment['secureFile']->getData(), - 'download_url' => $attachment['downloadURL']->getData(), - ]; - - try { - $attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); - } catch (AttachmentDownloadException $attachmentDownloadException) { - $this->addFlash( - 'error', - $translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() - ); - } - } - - $this->commentHelper->setMessage($form['log_comment']->getData()); - - $em->persist($new_part); - $em->flush(); - $this->addFlash('success', 'part.created_flash'); - - //If a redirect URL was given, redirect there - if ($request->query->get('_redirect')) { - return $this->redirect($request->query->get('_redirect')); - } - - //Redirect to clone page if user wished that... - //@phpstan-ignore-next-line - if ('save_and_clone' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_clone', ['id' => $new_part->getID()]); - } - //@phpstan-ignore-next-line - if ('save_and_new' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_new'); - } - - return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); - } - - if ($form->isSubmitted() && !$form->isValid()) { - $this->addFlash('error', 'part.created_flash.invalid'); - } - - return $this->render('parts/edit/new_part.html.twig', - [ - 'part' => $new_part, - 'form' => $form, - ]); - } } \ No newline at end of file diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 62dbaf4e..1a96735c 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -36,6 +36,7 @@ use App\Exceptions\AttachmentDownloadException; use App\Form\Part\PartBaseType; use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\PartPreviewGenerator; +use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\TimeTravel; @@ -63,7 +64,11 @@ use function Symfony\Component\Translation\t; #[Route(path: '/part')] class PartController extends AbstractController { - public function __construct(protected PricedetailHelper $pricedetailHelper, protected PartPreviewGenerator $partPreviewGenerator, protected EventCommentHelper $commentHelper) + public function __construct(protected PricedetailHelper $pricedetailHelper, + protected PartPreviewGenerator $partPreviewGenerator, + private readonly TranslatorInterface $translator, + private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em, + protected EventCommentHelper $commentHelper) { } @@ -121,65 +126,15 @@ class PartController extends AbstractController } #[Route(path: '/{id}/edit', name: 'part_edit')] - public function edit(Part $part, Request $request, EntityManagerInterface $em, TranslatorInterface $translator, - AttachmentSubmitHandler $attachmentSubmitHandler): Response + public function edit(Part $part, Request $request): Response { $this->denyAccessUnlessGranted('edit', $part); - $form = $this->createForm(PartBaseType::class, $part); - - $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - //Upload passed files - $attachments = $form['attachments']; - foreach ($attachments as $attachment) { - /** @var FormInterface $attachment */ - $options = [ - 'secure_attachment' => $attachment['secureFile']->getData(), - 'download_url' => $attachment['downloadURL']->getData(), - ]; - - try { - $attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); - } catch (AttachmentDownloadException $attachmentDownloadException) { - $this->addFlash( - 'error', - $translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() - ); - } - } - - $this->commentHelper->setMessage($form['log_comment']->getData()); - - $em->persist($part); - $em->flush(); - $this->addFlash('success', 'part.edited_flash'); - - //Redirect to clone page if user wished that... - //@phpstan-ignore-next-line - if ('save_and_clone' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_clone', ['id' => $part->getID()]); - } - //@phpstan-ignore-next-line - if ('save_and_new' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_new'); - } - - //Reload form, so the SIUnitType entries use the new part unit - $form = $this->createForm(PartBaseType::class, $part); - } elseif ($form->isSubmitted() && !$form->isValid()) { - $this->addFlash('error', 'part.edited_flash.invalid'); - } - - return $this->render('parts/edit/edit_part_info.html.twig', - [ - 'part' => $part, - 'form' => $form, - ]); + return $this->renderPartForm('edit', $request, $part); } #[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])] - public function delete(Request $request, Part $part, EntityManagerInterface $entityManager): RedirectResponse + public function delete(Request $request, Part $part): RedirectResponse { $this->denyAccessUnlessGranted('delete', $part); @@ -188,10 +143,10 @@ class PartController extends AbstractController $this->commentHelper->setMessage($request->request->get('log_comment', null)); //Remove part - $entityManager->remove($part); + $this->em->remove($part); //Flush changes - $entityManager->flush(); + $this->em->flush(); $this->addFlash('success', 'part.deleted'); } @@ -262,7 +217,39 @@ class PartController extends AbstractController $new_part->addOrderdetail($orderdetail); } - $form = $this->createForm(PartBaseType::class, $new_part); + return $this->renderPartForm('new', $request, $new_part); + } + + #[Route('/from_info_provider/{providerKey}/{providerId}/create', name: 'info_providers_create_part')] + public function createFromInfoProvider(Request $request, string $providerKey, string $providerId, PartInfoRetriever $infoRetriever): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $new_part = $infoRetriever->dtoToPart($dto); + + return $this->renderPartForm('new', $request, $new_part, [ + 'info_provider_dto' => $dto, + ]); + } + + /** + * This function provides a common implementation for methods, which use the part form. + * @param Request $request + * @param Part $new_part + * @param array $form_options + * @return Response + */ + private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = []): Response + { + //Ensure that mode is either 'new' or 'edit + if (!in_array($mode, ['new', 'edit'])) { + throw new \InvalidArgumentException('Invalid mode given'); + } + + $new_part = $data; + + $form = $this->createForm(PartBaseType::class, $new_part, $form_options); $form->handleRequest($request); @@ -277,20 +264,24 @@ class PartController extends AbstractController ]; try { - $attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); + $this->attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); } catch (AttachmentDownloadException $attachmentDownloadException) { $this->addFlash( 'error', - $translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() + $this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() ); } } $this->commentHelper->setMessage($form['log_comment']->getData()); - $em->persist($new_part); - $em->flush(); - $this->addFlash('success', 'part.created_flash'); + $this->em->persist($new_part); + $this->em->flush(); + if ($mode === 'new') { + $this->addFlash('success', 'part.created_flash'); + } else if ($mode === 'edit') { + $this->addFlash('success', 'part.edited_flash'); + } //If a redirect URL was given, redirect there if ($request->query->get('_redirect')) { @@ -314,13 +305,20 @@ class PartController extends AbstractController $this->addFlash('error', 'part.created_flash.invalid'); } - return $this->render('parts/edit/new_part.html.twig', + if ($mode === 'new') { + $template = 'parts/edit/new_part.html.twig'; + } else if ($mode === 'edit') { + $template = 'parts/edit/edit_part_info.html.twig'; + } + + return $this->render($template, [ 'part' => $new_part, 'form' => $form, ]); } + #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { diff --git a/src/Services/Parts/PartFormHelper.php b/src/Services/Parts/PartFormHelper.php new file mode 100644 index 00000000..57735a39 --- /dev/null +++ b/src/Services/Parts/PartFormHelper.php @@ -0,0 +1,108 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\Parts; + +use App\Entity\Parts\Part; +use App\Exceptions\AttachmentDownloadException; +use App\Form\Part\PartBaseType; +use App\Services\Attachments\AttachmentSubmitHandler; +use App\Services\LogSystem\EventCommentHelper; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class PartFormHelper +{ + private function __construct(private readonly TranslatorInterface $translator, private readonly EventCommentHelper $commentHelper, + private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em, + private readonly FormFactoryInterface $formFactory) + { + + } + + public function renderCreateForm(Request $request, Part $new_part = null, array $form_options = []): Response + { + $form = $this->formFactory->create(PartBaseType::class, $new_part, $form_options); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + //Upload passed files + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var FormInterface $attachment */ + $options = [ + 'secure_attachment' => $attachment['secureFile']->getData(), + 'download_url' => $attachment['downloadURL']->getData(), + ]; + + try { + $this->attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); + } catch (AttachmentDownloadException $attachmentDownloadException) { + $this->addFlash( + 'error', + $this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() + ); + } + } + + $this->commentHelper->setMessage($form['log_comment']->getData()); + + $this->em->persist($new_part); + $this->em->flush(); + $this->addFlash('success', 'part.created_flash'); + + //If a redirect URL was given, redirect there + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + + //Redirect to clone page if user wished that... + //@phpstan-ignore-next-line + if ('save_and_clone' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_clone', ['id' => $new_part->getID()]); + } + //@phpstan-ignore-next-line + if ('save_and_new' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_new'); + } + + return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', 'part.created_flash.invalid'); + } + + return $this->render('parts/edit/new_part.html.twig', + [ + 'part' => $new_part, + 'form' => $form, + ]); + } +} \ No newline at end of file From f423fdf7f8cf13cb23846de1d9a3bf0ee8a1a436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 23:48:55 +0200 Subject: [PATCH 29/34] Fixed bug in DB schema, which prevented the creation of parts without info provider reference --- migrations/Version20230716184033.php | 4 ++-- src/Entity/Parts/InfoProviderReference.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/migrations/Version20230716184033.php b/migrations/Version20230716184033.php index 16e3e5b9..521e1b96 100644 --- a/migrations/Version20230716184033.php +++ b/migrations/Version20230716184033.php @@ -24,7 +24,7 @@ final class Version20230716184033 extends AbstractMultiPlatformMigration $this->addSql('ALTER TABLE groups ADD alternative_names LONGTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE manufacturers ADD alternative_names LONGTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE measurement_units ADD alternative_names LONGTEXT DEFAULT NULL'); - $this->addSql('ALTER TABLE parts ADD provider_reference_provider_key VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_id VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_url VARCHAR(255) DEFAULT NULL, ADD provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + $this->addSql('ALTER TABLE parts ADD provider_reference_provider_key VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_id VARCHAR(255) DEFAULT NULL, ADD provider_reference_provider_url VARCHAR(255) DEFAULT NULL, ADD provider_reference_last_updated DATETIME DEFAULT NULL'); $this->addSql('ALTER TABLE projects ADD alternative_names LONGTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE storelocations ADD alternative_names LONGTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE suppliers ADD alternative_names LONGTEXT DEFAULT NULL'); @@ -89,7 +89,7 @@ final class Version20230716184033 extends AbstractMultiPlatformMigration $this->addSql('ALTER TABLE measurement_units ADD COLUMN alternative_names CLOB DEFAULT NULL'); $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM parts'); $this->addSql('DROP TABLE parts'); - $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(255) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(255) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); $this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM __temp__parts'); $this->addSql('DROP TABLE __temp__parts'); $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)'); diff --git a/src/Entity/Parts/InfoProviderReference.php b/src/Entity/Parts/InfoProviderReference.php index 26e23d34..53b81a0a 100644 --- a/src/Entity/Parts/InfoProviderReference.php +++ b/src/Entity/Parts/InfoProviderReference.php @@ -49,7 +49,7 @@ class InfoProviderReference #[Column(type: 'string', nullable: true)] private ?string $provider_url = null; - #[Column(type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])] + #[Column(type: Types::DATETIME_MUTABLE, nullable: true, options: ['default' => null])] private ?\DateTimeInterface $last_updated = null; /** From 4c1c6701b3362662eb9cb18ce37bb6b3d9e1dbd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 16 Jul 2023 23:56:30 +0200 Subject: [PATCH 30/34] Test availability of the info provider pages --- .../Providers/TestProvider.php | 31 +++++++++++++++++-- .../ApplicationAvailabilityFunctionalTest.php | 5 +++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index b680513e..8b78c95a 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -23,8 +23,15 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Component\DependencyInjection\Attribute\When; +/** + * This is a provider, which is used during tests + */ +#[When(env: 'test')] class TestProvider implements InfoProviderInterface { @@ -50,7 +57,11 @@ class TestProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { - // TODO: Implement searchByKeyword() method. + return [ + new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'), + new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element2', name: 'Element 2', description: 'fd'), + new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element3', name: 'Element 3', description: 'fd'), + ]; } public function getCapabilities(): array @@ -63,6 +74,22 @@ class TestProvider implements InfoProviderInterface public function getDetails(string $id): PartDetailDTO { - // TODO: Implement getDetails() method. + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $id, + name: 'Test Element', + description: 'fd', + manufacturer: 'Test Manufacturer', + mpn: '1234', + provider_url: 'https://invalid.invalid', + footprint: 'Footprint', + notes: 'Notes', + datasheets: [ + new FileDTO('https://invalid.invalid/invalid.pdf', 'Datasheet') + ], + images: [ + new FileDTO('https://invalid.invalid/invalid.png', 'Image') + ] + ); } } \ No newline at end of file diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php index 399270b9..b6103a24 100644 --- a/tests/ApplicationAvailabilityFunctionalTest.php +++ b/tests/ApplicationAvailabilityFunctionalTest.php @@ -140,5 +140,10 @@ class ApplicationAvailabilityFunctionalTest extends WebTestCase yield ['/project/1/add_parts?parts=1,2']; yield ['/project/1/build?n=1']; yield ['/project/1/import_bom']; + + //Test info provider system + yield ['/tools/info_providers/providers']; //List all providers + yield ['/tools/info_providers/search']; //Search page + yield ['/part/from_info_provider/test/element1/create']; //Create part from info provider } } From 7b61cb3163f5456ed920018d1a97c8f81f605a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 17 Jul 2023 00:19:02 +0200 Subject: [PATCH 31/34] Added more env variables to configure providers --- .docker/symfony.conf | 4 +++ .env | 35 +++++++++++++++++++ config/services.yaml | 20 ++++++++--- .../Providers/DigikeyProvider.php | 9 +++-- .../Providers/Element14Provider.php | 14 ++++---- .../Providers/TMEProvider.php | 14 +++----- 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/.docker/symfony.conf b/.docker/symfony.conf index 0629d12c..a6b8d7a9 100644 --- a/.docker/symfony.conf +++ b/.docker/symfony.conf @@ -36,6 +36,10 @@ PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAMLP_SP_PRIVATE_KEY PassEnv TABLE_DEFAULT_PAGE_SIZE + PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY + PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID + PassEnv PROVIDER_TME_KEY PROVIDER_TME_SECRET PROVIDER_TME_CURRENCY PROVIDER_TME_LANGUAGE PROVIDER_TME_COUNTRY PROVIDER_TME_GET_GROSS_PRICES + # For most configuration files from conf-available/, which are # enabled or disabled at a global level, it is possible to # include a line for only one particular virtual host. For example the diff --git a/.env b/.env index ede0dd4b..d1a42993 100644 --- a/.env +++ b/.env @@ -91,6 +91,41 @@ ERROR_PAGE_SHOW_HELP=1 # The default page size for the part table (set to -1 to show all parts on one page) TABLE_DEFAULT_PAGE_SIZE=50 +################################################################################## +# Info provider settings +################################################################################## + +# Digikey Provider: +# You can get your client id and secret from https://developer.digikey.com/ +PROVIDER_DIGIKEY_CLIENT_ID= +PROVIDER_DIGIKEY_SECRET= +# The currency to get prices in +PROVIDER_DIGIKEY_CURRENCY=EUR +# The language to get results in (en, de, fr, it, es, zh, ja, ko) +PROVIDER_DIGIKEY_LANGUAGE=en +# The country to get results for +PROVIDER_DIGIKEY_COUNTRY=DE + +# Farnell Provider: +# You can get your API key from https://partner.element14.com/ +PROVIDER_ELEMENT14_KEY= +# Configure the store domain you want to use. This decides the language and currency of results. You can get a list of available stores from https://partner.element14.com/docs/Product_Search_API_REST__Description +PROVIDER_ELEMENT14_STORE_ID=de.farnell.com + +# TME Provider: +# You can get your API key from https://developers.tme.eu/en/ +PROVIDER_TME_KEY= +PROVIDER_TME_SECRET= +# The currency to get prices in +PROVIDER_TME_CURRENCY=EUR +# The language to get results in (en, de, pl) +PROVIDER_TME_LANGUAGE=en +# The country to get results for +PROVIDER_TME_COUNTRY=DE +# Set this to 1 to get gross prices (including VAT) instead of net prices +PROVIDER_TME_GET_GROSS_PRICES=1 + + ################################################################################### # SAML Single sign on-settings ################################################################################### diff --git a/config/services.yaml b/config/services.yaml index a026f63d..d8be9967 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -246,17 +246,27 @@ services: App\Services\InfoProviderSystem\Providers\Element14Provider: arguments: - $api_key: '%env(PROVIDER_ELEMENT14_KEY)%' + $api_key: '%env(string:PROVIDER_ELEMENT14_KEY)%' + $store_id: '%env(string:PROVIDER_ELEMENT14_STORE_ID)%' App\Services\InfoProviderSystem\Providers\DigikeyProvider: arguments: - $clientId: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' - $currency: '%partdb.default_currency%' + $clientId: '%env(string:PROVIDER_DIGIKEY_CLIENT_ID)%' + $currency: '%env(string:PROVIDER_DIGIKEY_CURRENCY)%' + $language: '%env(string:PROVIDER_DIGIKEY_LANGUAGE)%' + $country: '%env(string:PROVIDER_DIGIKEY_COUNTRY)%' App\Services\InfoProviderSystem\Providers\TMEClient: arguments: - $secret: '%env(PROVIDER_TME_SECRET)%' - $token: '%env(PROVIDER_TME_KEY)%' + $secret: '%env(string:PROVIDER_TME_SECRET)%' + $token: '%env(string:PROVIDER_TME_KEY)%' + + App\Services\InfoProviderSystem\Providers\TMEProvider: + arguments: + $currency: '%env(string:PROVIDER_TME_CURRENCY)%' + $country: '%env(string:PROVIDER_TME_COUNTRY)%' + $language: '%env(string:PROVIDER_TME_LANGUAGE)%' + $get_gross_prices: '%env(bool:PROVIDER_TME_GET_GROSS_PRICES)%' #################################################################################################################### # Symfony overrides diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index b1196fc7..f85db0aa 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -46,15 +46,17 @@ class DigikeyProvider implements InfoProviderInterface private readonly HttpClientInterface $digikeyClient; - public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, private readonly string $currency, private readonly string $clientId) + public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, + private readonly string $currency, private readonly string $clientId, + private readonly string $language, private readonly string $country) { //Create the HTTP client with some default options $this->digikeyClient = $httpClient->withOptions([ "base_uri" => self::BASE_URI, "headers" => [ "X-DIGIKEY-Client-Id" => $clientId, - "X-DIGIKEY-Locale-Site" => 'DE', - "X-DIGIKEY-Locale-Language" => 'de', + "X-DIGIKEY-Locale-Site" => $this->country, + "X-DIGIKEY-Locale-Language" => $this->language, "X-DIGIKEY-Locale-Currency" => $this->currency, "X-DIGIKEY-Customer-Id" => 0, ] @@ -68,6 +70,7 @@ class DigikeyProvider implements InfoProviderInterface 'description' => 'This provider uses the DigiKey API to search for parts.', 'url' => 'https://www.digikey.com/', 'oauth_app_name' => self::OAUTH_APP_NAME, + 'disabled_help' => 'Set the PROVIDER_DIGIKEY_CLIENT_ID and PROVIDER_DIGIKEY_SECRET env option and connect OAuth to enable.' ]; } diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index b1954c51..d4383680 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -37,7 +37,6 @@ class Element14Provider implements InfoProviderInterface { private const ENDPOINT_URL = 'https://api.element14.com/catalog/products'; - private const FARNELL_STORE_ID = 'de.farnell.com'; private const API_VERSION_NUMBER = '1.2'; private const NUMBER_OF_RESULTS = 20; @@ -46,7 +45,7 @@ class Element14Provider implements InfoProviderInterface private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; - public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key) + public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id) { } @@ -57,6 +56,7 @@ class Element14Provider implements InfoProviderInterface 'name' => 'Farnell element14', 'description' => 'This provider uses the Farnell element14 API to search for parts.', 'url' => 'https://www.element14.com/', + 'disabled_help' => 'Configure the API key in the PROVIDER_ELEMENT14_KEY environment variable to enable.' ]; } @@ -79,7 +79,7 @@ class Element14Provider implements InfoProviderInterface $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ 'query' => [ 'term' => $term, - 'storeInfo.id' => self::FARNELL_STORE_ID, + 'storeInfo.id' => $this->store_id, 'resultsSettings.offset' => 0, 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, 'resultsSettings.responseGroup' => 'large', @@ -121,7 +121,7 @@ class Element14Provider implements InfoProviderInterface private function generateProductURL($sku): string { - return 'https://' . self::FARNELL_STORE_ID . '/' . $sku; + return 'https://' . $this->store_id . '/' . $sku; } /** @@ -154,7 +154,7 @@ class Element14Provider implements InfoProviderInterface $locale = 'en_US'; } - return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; + return 'https://' . $this->store_id . '/productimages/standard/' . $locale . $image['baseName']; } /** @@ -189,7 +189,7 @@ class Element14Provider implements InfoProviderInterface public function getUsedCurrency(): string { //Decide based on the shop ID - return match (self::FARNELL_STORE_ID) { + return match ($this->store_id) { 'bg.farnell.com' => 'EUR', 'cz.farnell.com' => 'CZK', 'dk.farnell.com' => 'DKK', @@ -237,7 +237,7 @@ class Element14Provider implements InfoProviderInterface 'tw.element14.com' => 'TWD', 'kr.element14.com' => 'KRW', 'vn.element14.com' => 'VND', - default => throw new \RuntimeException('Unknown store ID: ' . self::FARNELL_STORE_ID) + default => throw new \RuntimeException('Unknown store ID: ' . $this->store_id) }; } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 1540e82a..213f1bbc 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -38,15 +38,10 @@ class TMEProvider implements InfoProviderInterface private const VENDOR_NAME = 'TME'; - private string $country = 'DE'; - private string $language = 'en'; - private string $currency = 'EUR'; - /** - * @var bool If true, the prices are gross prices. If false, the prices are net prices. - */ - private bool $get_gross_prices = true; - - public function __construct(private readonly TMEClient $tmeClient) + public function __construct(private readonly TMEClient $tmeClient, private readonly string $country, + private readonly string $language, private readonly string $currency, + /** @var bool If true, the prices are gross prices. If false, the prices are net prices. */ + private readonly string $get_gross_prices) { } @@ -57,6 +52,7 @@ class TMEProvider implements InfoProviderInterface 'name' => 'TME', 'description' => 'This provider uses the API of TME (Transfer Multipart).', 'url' => 'https://tme.eu/', + 'disabled_help' => 'Configure the PROVIDER_TME_KEY and PROVIDER_TME_SECRET environment variables to use this provider.' ]; } From d10d29e590e62c6a400cd313432fad510690e25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 17 Jul 2023 00:20:38 +0200 Subject: [PATCH 32/34] Do not enable the create part from provider permission automatically This allows users to create new datastructures, which is maybe not wanted. Besides it has to be configured first. --- src/Entity/UserSystem/PermissionData.php | 2 +- src/Services/UserSystem/PermissionSchemaUpdater.php | 9 --------- .../UserSystem/PermissionSchemaUpdaterTest.php | 13 ------------- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index 38f4b774..01bb2416 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 3; + public const CURRENT_SCHEMA_VERSION = 2; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index e716bcc9..5fb08182 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -138,13 +138,4 @@ class PermissionSchemaUpdater $holder->getPermissions()->removePermission('devices'); } } - - private function upgradeSchemaToVersion3(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection - { - //If the info_providers permissions are not defined yet, set it if the user can create parts - if (!$holder->getPermissions()->isAnyOperationOfPermissionSet('info_providers')) { - $user_can_create_parts = $holder->getPermissions()->getPermissionValue('parts', 'create'); - $holder->getPermissions()->setPermissionValue('info_providers', 'create_parts', $user_can_create_parts); - } - } } diff --git a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php index b1a0e150..1acadd14 100644 --- a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php +++ b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php @@ -110,17 +110,4 @@ class PermissionSchemaUpdaterTest extends WebTestCase self::assertEquals(PermissionData::INHERIT, $user->getPermissions()->getPermissionValue('projects', 'edit')); self::assertEquals(PermissionData::DISALLOW, $user->getPermissions()->getPermissionValue('projects', 'delete')); } - - public function testUpgradeSchemaToVersion3(): void - { - $perm_data = new PermissionData(); - $perm_data->setSchemaVersion(2); - $perm_data->setPermissionValue('parts', 'create', PermissionData::ALLOW); - $user = new TestPermissionHolder($perm_data); - - //After the upgrade the user should be allowed to create parts from info providers - self::assertTrue($this->service->upgradeSchema($user, 3)); - - self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('info_providers', 'create_parts')); - } } From afcbbe0f43a1a74e43dd01947f00db5f01bb4434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 17 Jul 2023 00:34:00 +0200 Subject: [PATCH 33/34] Fixed phpunit tests --- src/DataFixtures/PartFixtures.php | 3 ++- src/Services/LabelSystem/LabelExampleElementsGenerator.php | 3 ++- .../LabelSystem/PlaceholderProviders/PartProvider.php | 4 ++-- tests/Services/ImportExportSystem/EntityImporterTest.php | 6 +++--- .../LabelSystem/PlaceholderProviders/PartProviderTest.php | 3 ++- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/DataFixtures/PartFixtures.php b/src/DataFixtures/PartFixtures.php index 3efb8dc8..477d0dd3 100644 --- a/src/DataFixtures/PartFixtures.php +++ b/src/DataFixtures/PartFixtures.php @@ -46,6 +46,7 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use App\Entity\Parts\Storelocation; @@ -83,7 +84,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface $part->setTags('test, Test, Part2'); $part->setMass(100.2); $part->setNeedsReview(true); - $part->setManufacturingStatus('active'); + $part->setManufacturingStatus(ManufacturingStatus::ACTIVE); $manager->persist($part); /** Part with orderdetails, storelocations and Attachments */ diff --git a/src/Services/LabelSystem/LabelExampleElementsGenerator.php b/src/Services/LabelSystem/LabelExampleElementsGenerator.php index d7c76c73..61cbcc4a 100644 --- a/src/Services/LabelSystem/LabelExampleElementsGenerator.php +++ b/src/Services/LabelSystem/LabelExampleElementsGenerator.php @@ -46,6 +46,7 @@ use App\Entity\LabelSystem\LabelSupportedElement; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use App\Entity\Parts\Storelocation; @@ -79,7 +80,7 @@ final class LabelExampleElementsGenerator $part->setMass(123.4); $part->setManufacturerProductNumber('CUSTOM MPN'); $part->setTags('Tag1, Tag2, Tag3'); - $part->setManufacturingStatus('active'); + $part->setManufacturingStatus(ManufacturingStatus::ACTIVE); $part->updateTimestamps(); $part->setFavorite(true); diff --git a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php index eb9d7078..0df4d3d7 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php +++ b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php @@ -105,11 +105,11 @@ final class PartProvider implements PlaceholderProviderInterface } if ('[[M_STATUS]]' === $placeholder) { - if ('' === $part->getManufacturingStatus()) { + if (null === $part->getManufacturingStatus()) { return ''; } - return $this->translator->trans('m_status.'.$part->getManufacturingStatus()); + return $this->translator->trans($part->getManufacturingStatus()->toTranslationKey()); } $parsedown = new Parsedown(); diff --git a/tests/Services/ImportExportSystem/EntityImporterTest.php b/tests/Services/ImportExportSystem/EntityImporterTest.php index b7dd26d0..f560240c 100644 --- a/tests/Services/ImportExportSystem/EntityImporterTest.php +++ b/tests/Services/ImportExportSystem/EntityImporterTest.php @@ -215,7 +215,7 @@ EOT; $this->assertSame($category, $results[1]->getCategory()); $input = <<assertInstanceOf(Part::class, $error['entity']); - $this->assertSame('Test 2', $error['entity']->getName()); + $this->assertSame('', $error['entity']->getName()); $this->assertContainsOnlyInstancesOf(ConstraintViolation::class, $error['violations']); //Element name must be element name - $this->assertArrayHasKey('Test 2', $errors); + $this->assertArrayHasKey('', $errors); //Check the valid element $this->assertSame('Test 1', $results[0]->getName()); diff --git a/tests/Services/LabelSystem/PlaceholderProviders/PartProviderTest.php b/tests/Services/LabelSystem/PlaceholderProviders/PartProviderTest.php index 975a5fd5..db3ebad1 100644 --- a/tests/Services/LabelSystem/PlaceholderProviders/PartProviderTest.php +++ b/tests/Services/LabelSystem/PlaceholderProviders/PartProviderTest.php @@ -41,6 +41,7 @@ declare(strict_types=1); namespace App\Tests\Services\LabelSystem\PlaceholderProviders; +use App\Entity\Parts\ManufacturingStatus; use Doctrine\ORM\EntityManager; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -80,7 +81,7 @@ class PartProviderTest extends WebTestCase $this->target->setMass(1234.2); $this->target->setTags('SMD, Tag1, Tag2'); $this->target->setManufacturerProductNumber('MPN123'); - $this->target->setManufacturingStatus('active'); + $this->target->setManufacturingStatus(ManufacturingStatus::ACTIVE); $this->target->setDescription('Bold *Italic*'); $this->target->setComment('Bold *Italic*'); From 3a8c5a788fc9250d59a10c63a608f5969617bc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 17 Jul 2023 00:43:35 +0200 Subject: [PATCH 34/34] Fixed phpstan issues --- src/Controller/OAuthClientController.php | 2 +- src/Controller/PartController.php | 5 +- .../PartTraits/AdvancedPropertyTrait.php | 4 +- .../DTOtoEntityConverter.php | 6 +- .../InfoProviderSystem/PartInfoRetriever.php | 2 +- .../InfoProviderSystem/ProviderRegistry.php | 2 +- .../Providers/Element14Provider.php | 6 +- .../Providers/TMEProvider.php | 2 +- src/Services/OAuth/OAuthTokenManager.php | 6 +- src/Services/Parts/PartFormHelper.php | 108 ------------------ src/Twig/UserExtension.php | 6 +- 11 files changed, 25 insertions(+), 124 deletions(-) delete mode 100644 src/Services/Parts/PartFormHelper.php diff --git a/src/Controller/OAuthClientController.php b/src/Controller/OAuthClientController.php index 71d8ec1d..ff2aab0e 100644 --- a/src/Controller/OAuthClientController.php +++ b/src/Controller/OAuthClientController.php @@ -47,7 +47,7 @@ class OAuthClientController extends AbstractController return $this->clientRegistry ->getClient($name) // key used in config/packages/knpu_oauth2_client.yaml - ->redirect(); + ->redirect([], []); } #[Route('/{name}/check', name: 'oauth_client_check')] diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 1a96735c..5b80a5cb 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -236,14 +236,14 @@ class PartController extends AbstractController /** * This function provides a common implementation for methods, which use the part form. * @param Request $request - * @param Part $new_part + * @param Part $data * @param array $form_options * @return Response */ private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = []): Response { //Ensure that mode is either 'new' or 'edit - if (!in_array($mode, ['new', 'edit'])) { + if (!in_array($mode, ['new', 'edit'], true)) { throw new \InvalidArgumentException('Invalid mode given'); } @@ -305,6 +305,7 @@ class PartController extends AbstractController $this->addFlash('error', 'part.created_flash.invalid'); } + $template = ''; if ($mode === 'new') { $template = 'parts/edit/new_part.html.twig'; } else if ($mode === 'edit') { diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 51bce445..648cf2a5 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -169,9 +169,9 @@ trait AdvancedPropertyTrait /** * Sets the reference to the info provider, that provided the information about this part. * @param InfoProviderReference $providerReference - * @return AdvancedPropertyTrait + * @return Part */ - public function setProviderReference(InfoProviderReference $providerReference): self + public function setProviderReference(InfoProviderReference $providerReference): Part { $this->providerReference = $providerReference; return $this; diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index a12628ac..4a359a03 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -231,7 +231,7 @@ final class DTOtoEntityConverter * @phpstan-param class-string $class * @param string $name The name of the entity to create * @return AbstractStructuralDBElement - * @phpstan-return T|null + * @phpstan-return T */ private function getOrCreateEntityNonNull(string $class, string $name): AbstractStructuralDBElement { @@ -263,7 +263,7 @@ final class DTOtoEntityConverter $tmp = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::TYPE_DATASHEETS_NAME); //If the entity was newly created, set the file filter - if ($tmp->getId() === null) { + if ($tmp->getID() === null) { $tmp->setFiletypeFilter('application/pdf'); $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); } @@ -281,7 +281,7 @@ final class DTOtoEntityConverter $tmp = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::TYPE_IMAGE_NAME); //If the entity was newly created, set the file filter - if ($tmp->getId() === null) { + if ($tmp->getID() === null) { $tmp->setFiletypeFilter('image/*'); $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); } diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 17f4acbc..f9bf4d84 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -86,7 +86,7 @@ final class PartInfoRetriever * The result is cached for 4 days. * @param string $provider_key * @param string $part_id - * @return + * @return PartDetailDTO */ public function getDetails(string $provider_key, string $part_id): PartDetailDTO { diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php index 921430e0..46f2484b 100644 --- a/src/Services/InfoProviderSystem/ProviderRegistry.php +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -49,7 +49,7 @@ final class ProviderRegistry /** * @param iterable $providers */ - public function __construct(private readonly iterable $providers) + public function __construct(iterable $providers) { foreach ($providers as $provider) { $key = $provider->getProviderKey(); diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index d4383680..7cc6693b 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -243,9 +243,9 @@ class Element14Provider implements InfoProviderInterface /** * @param array|null $attributes - * @return ParameterDTO[]|null + * @return ParameterDTO[] */ - private function attributesToParameters(?array $attributes): ?array + private function attributesToParameters(?array $attributes): array { $result = []; @@ -258,7 +258,7 @@ class Element14Provider implements InfoProviderInterface } //tariffCode is a special case, we prepend a # to prevent conversion to float - if (in_array($attribute['attributeLabel'], ['tariffCode', 'hazardCode'])) { + if (in_array($attribute['attributeLabel'], ['tariffCode', 'hazardCode'], true)) { $attribute['attributeValue'] = '#' . $attribute['attributeValue']; } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 213f1bbc..2d12b222 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -41,7 +41,7 @@ class TMEProvider implements InfoProviderInterface public function __construct(private readonly TMEClient $tmeClient, private readonly string $country, private readonly string $language, private readonly string $currency, /** @var bool If true, the prices are gross prices. If false, the prices are net prices. */ - private readonly string $get_gross_prices) + private readonly bool $get_gross_prices) { } diff --git a/src/Services/OAuth/OAuthTokenManager.php b/src/Services/OAuth/OAuthTokenManager.php index bf4dcaa1..020eead7 100644 --- a/src/Services/OAuth/OAuthTokenManager.php +++ b/src/Services/OAuth/OAuthTokenManager.php @@ -50,6 +50,7 @@ final class OAuthTokenManager if ($tokenEntity) { $tokenEntity->replaceWithNewToken($token); + //@phpstan-ignore-next-line $this->entityManager->flush($tokenEntity); //We are done @@ -59,6 +60,7 @@ final class OAuthTokenManager //If the token was not existing, we create a new one $tokenEntity = OAuthToken::fromAccessToken($token, $app_name); $this->entityManager->persist($tokenEntity); + //@phpstan-ignore-next-line $this->entityManager->flush($tokenEntity); return; @@ -104,6 +106,8 @@ final class OAuthTokenManager //Persist the token $token->replaceWithNewToken($new_token); + + //@phpstan-ignore-next-line $this->entityManager->flush($token); return $token; @@ -112,7 +116,7 @@ final class OAuthTokenManager /** * This function returns the token of the given app name * @param string $app_name - * @return OAuthToken|null + * @return string|null */ public function getAlwaysValidTokenString(string $app_name): ?string { diff --git a/src/Services/Parts/PartFormHelper.php b/src/Services/Parts/PartFormHelper.php deleted file mode 100644 index 57735a39..00000000 --- a/src/Services/Parts/PartFormHelper.php +++ /dev/null @@ -1,108 +0,0 @@ -. - */ - -declare(strict_types=1); - - -namespace App\Services\Parts; - -use App\Entity\Parts\Part; -use App\Exceptions\AttachmentDownloadException; -use App\Form\Part\PartBaseType; -use App\Services\Attachments\AttachmentSubmitHandler; -use App\Services\LogSystem\EventCommentHelper; -use Doctrine\ORM\EntityManagerInterface; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormFactoryInterface; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\Translation\TranslatorInterface; - -final class PartFormHelper -{ - private function __construct(private readonly TranslatorInterface $translator, private readonly EventCommentHelper $commentHelper, - private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em, - private readonly FormFactoryInterface $formFactory) - { - - } - - public function renderCreateForm(Request $request, Part $new_part = null, array $form_options = []): Response - { - $form = $this->formFactory->create(PartBaseType::class, $new_part, $form_options); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - //Upload passed files - $attachments = $form['attachments']; - foreach ($attachments as $attachment) { - /** @var FormInterface $attachment */ - $options = [ - 'secure_attachment' => $attachment['secureFile']->getData(), - 'download_url' => $attachment['downloadURL']->getData(), - ]; - - try { - $this->attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); - } catch (AttachmentDownloadException $attachmentDownloadException) { - $this->addFlash( - 'error', - $this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() - ); - } - } - - $this->commentHelper->setMessage($form['log_comment']->getData()); - - $this->em->persist($new_part); - $this->em->flush(); - $this->addFlash('success', 'part.created_flash'); - - //If a redirect URL was given, redirect there - if ($request->query->get('_redirect')) { - return $this->redirect($request->query->get('_redirect')); - } - - //Redirect to clone page if user wished that... - //@phpstan-ignore-next-line - if ('save_and_clone' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_clone', ['id' => $new_part->getID()]); - } - //@phpstan-ignore-next-line - if ('save_and_new' === $form->getClickedButton()->getName()) { - return $this->redirectToRoute('part_new'); - } - - return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); - } - - if ($form->isSubmitted() && !$form->isValid()) { - $this->addFlash('error', 'part.created_flash.invalid'); - } - - return $this->render('parts/edit/new_part.html.twig', - [ - 'part' => $new_part, - 'form' => $form, - ]); - } -} \ No newline at end of file diff --git a/src/Twig/UserExtension.php b/src/Twig/UserExtension.php index 0a06ef2d..93ea57be 100644 --- a/src/Twig/UserExtension.php +++ b/src/Twig/UserExtension.php @@ -97,7 +97,11 @@ final class UserExtension extends AbstractExtension { $token = $this->security->getToken(); if ($token instanceof SwitchUserToken) { - return $token->getOriginalToken()->getUser(); + $tmp = $token->getOriginalToken()->getUser(); + + if ($tmp instanceof User) { + return $tmp; + } } return null;
NameDescriptionManufactuerMPNStatusProvider
{% trans %}name.label{% endtrans %} / {% trans %}part.table.mpn{% endtrans %}{% trans %}description.label{% endtrans %} / {% trans %}category.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %} / {% trans %}footprint.label{% endtrans %}{% trans %}part.table.manufacturingStatus{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}
{{ result.name }}{{ result.description }}{{ result.manufacturer ?? '' }}{{ result.mpn ?? '' }}{{ helper.m_status_to_badge(result.manufacturing_status) }}{{ result.provider_key }}: {{ result.provider_id }} - + + + {% if result.provider_url is not null %} + {{ result.name }} + {% else %} + {{ result.name }} + {% endif %} + + {% if result.mpn is not null %} +
+ {{ result.mpn }} + {% endif %} +
+ {{ result.description }} + {% if result.category is not null %} +
+ {{ result.category }} + {% endif %} +
+ {{ result.manufacturer ?? '' }} + {% if result.footprint is not null %} +
+ {{ result.footprint }} + {% endif %} +
{{ helper.m_status_to_badge(result.manufacturing_status) }} + {% if result.provider_url %} + + {{ info_provider_label(result.provider_key)|default(result.provider_key) }} + + {% else %} + {{ info_provider_label(result.provider_key)|default(result.provider_key) }} + {% endif %} +
+ {{ result.provider_id }} +
+