Merge branch 'octopart-ip'

This commit is contained in:
Jan Böhmer 2023-07-31 22:43:19 +02:00
commit 2393c759f3
18 changed files with 626 additions and 19 deletions

View file

@ -39,6 +39,7 @@
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
PassEnv PROVIDER_OCTOPART_CLIENT_ID PROVIDER_OCTOPART_SECRET PROVIDER_OCTOPART_CURRENCY PROVIDER_OCTOPART_COUNTRY PROVIDER_OCTOPART_SEARCH_LIMIT PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS
# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to

14
.env
View file

@ -125,6 +125,20 @@ PROVIDER_TME_COUNTRY=DE
# Set this to 1 to get gross prices (including VAT) instead of net prices
PROVIDER_TME_GET_GROSS_PRICES=1
# Octopart / Nexar Provider:
# You can get your API key from https://nexar.com/api
PROVIDER_OCTOPART_CLIENT_ID=
PROVIDER_OCTOPART_SECRET=
# The currency and country to get prices for (you have to set both to get meaningful results)
# 3 letter ISO currency code (e.g. EUR, USD, GBP)
PROVIDER_OCTOPART_CURRENCY=EUR
# 2 letter ISO country code (e.g. DE, US, GB)
PROVIDER_OCTOPART_COUNTRY=DE
# The number of results to get from Octopart while searching (please note that this counts towards your API limits)
PROVIDER_OCTOPART_SEARCH_LIMIT=10
# Set to false to include non authorized offers in the results
PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS=1
###################################################################################
# SAML Single sign on-settings

View file

@ -51,7 +51,7 @@ Two-factor authentication is supported (Google Authenticator and Webauthn/U2F ke
* Support for multiple currencies and automatic update of exchange rates supported
* Powerful search and filter function, including parametric search (search for parts according to some specifications)
* Automatic thumbnail generation for pictures
* Use cloud providers (like Digikey, farnell or TME) to automatically get part information, datasheets and prices for parts
* Use cloud providers (like Octopart, Digikey, farnell or TME) to automatically get part information, datasheets and prices for parts
With these features Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,

View file

@ -21,3 +21,18 @@ knpu_oauth2_client:
#urlAuthorize: 'https://sandbox-api.digikey.com/v1/oauth2/authorize'
#urlAccessToken: 'https://sandbox-api.digikey.com/v1/oauth2/token'
#urlResourceOwnerDetails: ''
ip_octopart_oauth:
type: generic
provider_class: '\League\OAuth2\Client\Provider\GenericProvider'
client_id: '%env(PROVIDER_OCTOPART_CLIENT_ID)%'
client_secret: '%env(PROVIDER_OCTOPART_SECRET)%'
redirect_route: 'oauth_client_check'
redirect_params: { name: 'ip_octopart_oauth' }
provider_options:
urlAuthorize: 'https://identity.nexar.com/connect/authorize'
urlAccessToken: 'https://identity.nexar.com/connect/token'
urlResourceOwnerDetails: ''

View file

@ -16,8 +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
# Whitelist the info provider APIs (OAuth redirects)
- 'digikey.com'
- 'nexar.com'
# forces Microsoft's XSS-Protection with
# its block mode

View file

@ -268,6 +268,15 @@ services:
$language: '%env(string:PROVIDER_TME_LANGUAGE)%'
$get_gross_prices: '%env(bool:PROVIDER_TME_GET_GROSS_PRICES)%'
App\Services\InfoProviderSystem\Providers\OctopartProvider:
arguments:
$clientId: '&env(string:PROVIDER_OCTOPART_CLIENT_ID)%'
$secret: '%env(string:PROVIDER_OCTOPART_SECRET)%'
$country: '%env(string:PROVIDER_OCTOPART_COUNTRY)%'
$currency: '%env(string:PROVIDER_OCTOPART_CURRENCY)%'
$search_limit: '%env(int:PROVIDER_OCTOPART_SEARCH_LIMIT)%'
$onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%'
####################################################################################################################
# Symfony overrides
####################################################################################################################

View file

@ -37,7 +37,7 @@ It is installed on a web server and so can be accessed with any browser without
* Support for multiple currencies and automatic update of exchange rates supported
* Powerful search and filter function, including parametric search (search for parts according to some specifications)
* Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %}))
* Use cloud providers (like Digikey, farnell or TME) to automatically get part information, datasheets and prices for parts (see [here]({% link usage/information_provider_system.md %}))
* Use cloud providers (like Octopart, Digikey, farnell or TME) to automatically get part information, datasheets and prices for parts (see [here]({% link usage/information_provider_system.md %}))
With these features Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,
or makerspaces, where many users have should have (controlled) access to the shared inventory.

View file

@ -64,6 +64,26 @@ The following providers are currently available and shipped with Part-DB:
(All trademarks are property of their respective owners. Part-DB is not affiliated with any of the companies.)
### Ocotpart
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and getting informations.
To use it you have to create an account at Nexar and create a new application on the [Nexar Portal](https://portal.nexar.com/).
The name does not matter, but it is important that the application has access to the "Supply" scope.
In the Authorization tab, you will find the client ID and client secret, which you have to enter in the Part-DB env configuration (see below).
Please note that the Nexar API in the free plan is limited to 1000 results per month.
That means if you search for a keyword and results in 10 parts, then 10 will be substracted from your monthly limit. You can see your current usage on the Nexar portal.
Part-DB caches the search results internally, so if you have searched for a part before, it will not count against your monthly limit again, when you create it from the search results.
Following env configuration options are available:
* `PROVIDER_OCTOPART_CLIENT_ID`: The client ID you got from Nexar (mandatory)
* `PROVIDER_OCTOPART_CLIENT_SECRET`: The client secret you got from Nexar (mandatory)
* `PROVIDER_OCTOPART_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`). If an offer is only available in a certain currency,
Part-DB will save the prices in their native currency, and you can use Part-DB currency conversion feature to convert it to your preferred currency.
* `PROVIDER_OCOTPART_COUNTRY`: The country you want to get prices in if available (optional, 2 letter ISO-code, default: `DE`). To get correct prices, you have to set this and the currency setting to the correct value.
* `PROVIDER_OCTOPART_SEARCH_LIMIT`: The maximum number of results to return per search (optional, default: `10`). This affects how quickly your monthly limit is used up.
* `PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS`: If set to `true`, only offers from [authorized sellers](https://octopart.com/authorized) will be returned (optional, default: `false`).
### Digi-Key
The Digi-Key provider uses the [Digi-Key API](https://developer.digikey.com/) to search for parts and getting shopping information from [Digi-Key](https://www.digikey.com/).
To use it you have to create an account at Digi-Key and get an API key on the [Digi-Key API page](https://developer.digikey.com/).

View file

@ -38,7 +38,7 @@ final class Version20230417211732 extends AbstractMultiPlatformMigration
public function sqLiteUp(Schema $schema): void
{
//As legacy database can only be migrated to MySQL, we don't need to implement this method.
$this->skipIf(true, 'Not needed for SQLite');
//Dont skip here, as this causes this migration always to be executed. Do nothing instead.
}
public function sqLiteDown(Schema $schema): void

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20230730131708 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Allow longer fields for manufacturer and supplier product urls, allow client credentials grant to have null refresh token';
}
public function mySQLUp(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE oauth_tokens CHANGE token token LONGTEXT DEFAULT NULL, CHANGE refresh_token refresh_token LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE orderdetails CHANGE supplier_product_url supplier_product_url LONGTEXT NOT NULL');
$this->addSql('ALTER TABLE parts CHANGE manufacturer_product_url manufacturer_product_url LONGTEXT NOT NULL');
}
public function mySQLDown(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE oauth_tokens CHANGE token token VARCHAR(255) DEFAULT NULL, CHANGE refresh_token refresh_token VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE `orderdetails` CHANGE supplier_product_url supplier_product_url VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE `parts` CHANGE manufacturer_product_url manufacturer_product_url VARCHAR(255) NOT NULL');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__oauth_tokens AS SELECT id, token, expires_at, refresh_token, name, last_modified, datetime_added FROM oauth_tokens');
$this->addSql('DROP TABLE oauth_tokens');
$this->addSql('CREATE TABLE oauth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, token CLOB DEFAULT NULL, expires_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
, refresh_token CLOB DEFAULT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL)');
$this->addSql('INSERT INTO oauth_tokens (id, token, expires_at, refresh_token, name, last_modified, datetime_added) SELECT id, token, expires_at, refresh_token, name, last_modified, datetime_added FROM __temp__oauth_tokens');
$this->addSql('DROP TABLE __temp__oauth_tokens');
$this->addSql('CREATE UNIQUE INDEX oauth_tokens_unique_name ON oauth_tokens (name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added FROM orderdetails');
$this->addSql('DROP TABLE orderdetails');
$this->addSql('CREATE TABLE orderdetails (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES suppliers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO orderdetails (id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added) SELECT id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added FROM __temp__orderdetails');
$this->addSql('DROP TABLE __temp__orderdetails');
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON orderdetails (supplierpartnr)');
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON orderdetails (part_id)');
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON orderdetails (id_supplier)');
$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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated 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 CLOB 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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated) 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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
$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 UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__oauth_tokens AS SELECT id, token, expires_at, refresh_token, name, last_modified, datetime_added FROM oauth_tokens');
$this->addSql('DROP TABLE oauth_tokens');
$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('INSERT INTO oauth_tokens (id, token, expires_at, refresh_token, name, last_modified, datetime_added) SELECT id, token, expires_at, refresh_token, name, last_modified, datetime_added FROM __temp__oauth_tokens');
$this->addSql('DROP TABLE __temp__oauth_tokens');
$this->addSql('CREATE UNIQUE INDEX oauth_tokens_unique_name ON oauth_tokens (name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added FROM "orderdetails"');
$this->addSql('DROP TABLE "orderdetails"');
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "orderdetails" (id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added) SELECT id, part_id, id_supplier, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added FROM __temp__orderdetails');
$this->addSql('DROP TABLE __temp__orderdetails');
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
$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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated 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, 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_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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated) 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, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated 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)');
}
}

View file

@ -39,26 +39,34 @@ use League\OAuth2\Client\Token\AccessTokenInterface;
class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
{
/** @var string|null The short-term usable OAuth2 token */
#[ORM\Column(type: 'string', nullable: true)]
#[ORM\Column(type: 'text', 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 = '';
/** @var string|null The refresh token for the OAuth2 auth */
#[ORM\Column(type: 'text', nullable: true)]
private ?string $refresh_token = null;
/**
* The default expiration time for a authorization token, if no expiration time is given
*/
private const DEFAULT_EXPIRATION_TIME = 3600;
public function __construct(string $name, string $refresh_token, string $token = null, \DateTimeInterface $expires_at = null)
public function __construct(string $name, ?string $refresh_token, ?string $token = null, \DateTimeInterface $expires_at = null)
{
//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');
}
//If no refresh_token is given, the token is a client credentials grant token, which must have a token
if ($refresh_token === null && $token === null) {
throw new \InvalidArgumentException('If you give no refresh_token, you have to give a token!');
}
$this->name = $name;
$this->refresh_token = $refresh_token;
$this->expires_at = $expires_at;
@ -109,6 +117,16 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
return $this->expires_at->getTimestamp() < time();
}
/**
* Returns true if this token is a client credentials grant token (meaning it has no refresh token), and
* needs to be refreshed via the client credentials grant.
* @return bool
*/
public function isClientCredentialsGrant(): bool
{
return $this->refresh_token === null;
}
public function replaceWithNewToken(AccessTokenInterface $accessToken): void
{
$this->token = $accessToken->getToken();

View file

@ -50,7 +50,7 @@ trait ManufacturerTrait
*/
#[Assert\Url]
#[Groups(['full', 'import'])]
#[ORM\Column(type: Types::STRING)]
#[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';
/**

View file

@ -75,7 +75,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
*/
#[Assert\Url]
#[Groups(['full', 'import'])]
#[ORM\Column(type: Types::STRING)]
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';
/**

View file

@ -53,6 +53,8 @@ class PartDetailDTO extends SearchResultDTO
public readonly ?array $vendor_infos = null,
/** The mass of the product in grams */
public readonly ?float $mass = null,
/** The URL to the product on the website of the manufacturer */
public readonly ?string $manufacturer_product_url = null,
) {
parent::__construct(
provider_key: $provider_key,

View file

@ -162,6 +162,7 @@ final class DTOtoEntityConverter
$entity->setManufacturerProductNumber($dto->mpn ?? '');
$entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET);
$entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
//Set the provider reference on the part
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));

View file

@ -75,7 +75,6 @@ final class PartInfoRetriever
{
//Generate key and escape reserved characters from the provider id
$escaped_keyword = urlencode($keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION);
@ -97,8 +96,6 @@ final class PartInfoRetriever
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION);

View file

@ -0,0 +1,404 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\OAuth\OAuthTokenManager;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* This class implements the Octopart/Nexar API as an InfoProvider
*
* As the limits for Octopart are quite limited, we use an additional layer of caching here, we get the full parts during a search
* and cache them, so we can use them for the detail view without having to query the API again.
*/
class OctopartProvider implements InfoProviderInterface
{
private const OAUTH_APP_NAME = 'ip_octopart_oauth';
/**
* This defines what fields are returned in the answer from the Octopart API
*/
private const GRAPHQL_PART_SECTION = <<<'GRAPHQL'
{
id
mpn
octopartUrl
manufacturer {
name
}
shortDescription
category {
ancestors {
name
}
name
}
bestImage {
url
}
bestDatasheet {
url
name
}
manufacturerUrl
medianPrice1000 {
price
currency
quantity
}
sellers(authorizedOnly: $authorizedOnly) {
company {
name
}
isAuthorized
offers {
clickUrl
inventoryLevel
moq
sku
packaging
prices {
price
currency
quantity
}
}
},
specs {
attribute {
name
shortname
group
id
}
displayValue
value
siValue
units
unitsName
unitsSymbol
valueType
}
}
GRAPHQL;
public function __construct(private readonly HttpClientInterface $httpClient,
private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache,
private readonly string $clientId, private readonly string $secret,
private readonly string $currency, private readonly string $country,
private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers)
{
}
/**
* Gets the latest OAuth token for the Octopart API, or creates a new one if none is available
* @return string
*/
private function getToken(): string
{
//Check if we already have a token saved for this app, otherwise we have to retrieve one via OAuth
if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) {
$this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME);
}
$tmp = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME);
if ($tmp === null) {
throw new \RuntimeException('Could not retrieve OAuth token for Octopart');
}
return $tmp;
}
/**
* Make a GraphQL call to the Octopart API
* @return array
*/
private function makeGraphQLCall(string $query, ?array $variables = null): array
{
if ($variables === []) {
$variables = null;
}
$options = (new HttpOptions())
->setJson(['query' => $query, 'variables' => $variables])
->setAuthBearer($this->getToken())
;
$response = $this->httpClient->request(
'POST',
'https://api.nexar.com/graphql/',
$options->toArray(),
);
return $response->toArray(true);
}
public function getProviderInfo(): array
{
return [
'name' => 'Octopart',
'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
'url' => 'https://www.octopart.com/',
'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.'
];
}
public function getProviderKey(): string
{
return 'octopart';
}
public function isActive(): bool
{
//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);
return !empty($this->clientId) && !empty($this->secret);
}
private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus
{
return match ($value) {
'Production', 'New' => ManufacturingStatus::ACTIVE,
'Obsolete' => ManufacturingStatus::DISCONTINUED,
'NRND' => ManufacturingStatus::NRFND,
'EOL' => ManufacturingStatus::EOL,
default => null,
};
}
/**
* Saves the given part to the cache.
* Everytime this function is called, the cache is overwritten.
* @param PartDetailDTO $part
* @return void
*/
private function saveToCache(PartDetailDTO $part): void
{
$key = 'octopart_part_'.$part->provider_id;
$item = $this->partInfoCache->getItem($key);
$item->set($part);
$item->expiresAfter(3600 * 24 * 1); //Cache for 1 day
$this->partInfoCache->save($item);
}
/**
* Retrieves a from the cache, or null if it was not cached yet.
* @param string $id
* @return PartDetailDTO|null
*/
private function getFromCache(string $id): ?PartDetailDTO
{
$key = 'octopart_part_'.$id;
$item = $this->partInfoCache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
return null;
}
private function partResultToDTO(array $part): PartDetailDTO
{
//Parse the specifications
$parameters = [];
$mass = null;
$package = null;
$pinCount = null;
$mStatus = null;
foreach ($part['specs'] as $spec) {
//If we encounter the mass spec, we save it for later
if ($spec['attribute']['shortname'] === "weight") {
$mass = (float) $spec['siValue'];
} else if ($spec['attribute']['shortname'] === "case_package") { //Package
$package = $spec['value'];
} else if ($spec['attribute']['shortname'] === "numberofpins") { //Pin Count
$pinCount = $spec['value'];
} else if ($spec['attribute']['shortname'] === "lifecyclestatus") { //LifeCycleStatus
$mStatus = $this->mapLifeCycleStatus($spec['value']);
}
$parameters[] = new ParameterDTO(
name: $spec['attribute']['name'],
value_text: $spec['valueType'] === 'text' ? $spec['value'] : null,
value_typ: in_array($spec['valueType'], ['float', 'integer'], true) ? (float) $spec['value'] : null,
unit: $spec['valueType'] === 'text' ? null : $spec['units'],
group: $spec['attribute']['group'],
);
}
//Parse the offers
$orderinfos = [];
foreach ($part['sellers'] as $seller) {
foreach ($seller['offers'] as $offer) {
$prices = [];
foreach ($offer['prices'] as $price) {
$prices[] = new PriceDTO(
minimum_discount_amount: $price['quantity'],
price: (string) $price['price'],
currency_iso_code: $price['currency'],
);
}
$orderinfos[] = new PurchaseInfoDTO(
distributor_name: $seller['company']['name'],
order_number: $offer['sku'],
prices: $prices,
product_url: $offer['clickUrl'],
);
}
}
//Generate a footprint name from the package and pin count
$footprint = null;
if ($package !== null) {
$footprint = $package;
if ($pinCount !== null) { //Add pin count if available
$footprint .= '-' . $pinCount;
}
}
//Built the category full path
$category = null;
if (!empty($part['category']['name'])) {
$category = implode(' -> ', array_map(fn($c) => $c['name'], $part['category']['ancestors'] ?? []));
if (!empty($category)) {
$category .= ' -> ';
}
$category .= $part['category']['name'];
}
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $part['id'],
name: $part['mpn'],
description: $part['shortDescription'] ?? null,
category: $category ,
manufacturer: $part['manufacturer']['name'] ?? null,
mpn: $part['mpn'],
preview_image_url: $part['bestImage']['url'] ?? null,
manufacturing_status: $mStatus,
provider_url: $part['octopartUrl'] ?? null,
footprint: $footprint,
datasheets: $part['bestDatasheet'] !== null ? [new FileDTO($part['bestDatasheet']['url'], $part['bestDatasheet']['name'])]: null,
parameters: $parameters,
vendor_infos: $orderinfos,
mass: $mass,
manufacturer_product_url: $part['manufacturerUrl'] ?? null,
);
}
public function searchByKeyword(string $keyword): array
{
$graphQL = sprintf(<<<'GRAPHQL'
query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
supSearch(
q: $keyword
inStockOnly: false
limit: $limit
currency: $currency
country: $country
) {
hits
results {
part
%s
}
}
}
GRAPHQL, self::GRAPHQL_PART_SECTION);
$result = $this->makeGraphQLCall($graphQL, [
'keyword' => $keyword,
'limit' => $this->search_limit,
'currency' => $this->currency,
'country' => $this->country,
'authorizedOnly' => $this->onlyAuthorizedSellers,
]);
$tmp = [];
foreach ($result['data']['supSearch']['results'] ?? [] as $p) {
$dto = $this->partResultToDTO($p['part']);
$tmp[] = $dto;
//Cache the part, so we can get the details later, without having to make another request
$this->saveToCache($dto);
}
return $tmp;
}
public function getDetails(string $id): PartDetailDTO
{
//Check if we have the part cached
$cached = $this->getFromCache($id);
if ($cached !== null) {
return $cached;
}
//Otherwise we have to make a request
$graphql = sprintf(<<<'GRAPHQL'
query partSearch($ids: [String!]!, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
supParts(ids: $ids, currency: $currency, country: $country)
%s
}
GRAPHQL, self::GRAPHQL_PART_SECTION);
$result = $this->makeGraphQLCall($graphql, [
'ids' => [$id],
'currency' => $this->currency,
'country' => $this->country,
'authorizedOnly' => $this->onlyAuthorizedSellers,
]);
$tmp = $this->partResultToDTO($result['data']['supParts'][0]);
$this->saveToCache($tmp);
return $tmp;
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::FOOTPRINT,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
];
}
}

View file

@ -39,9 +39,9 @@ final class OAuthTokenManager
* Saves the given token to the database, so it can be retrieved later
* @param string $app_name
* @param AccessTokenInterface $token
* @return void
* @return OAuthToken The saved token as database entity
*/
public function saveToken(string $app_name, AccessTokenInterface $token): void
public function saveToken(string $app_name, AccessTokenInterface $token): OAuthToken
{
//Check if we already have a token for this app
$tokenEntity = $this->entityManager->getRepository(OAuthToken::class)->findOneBy(['name' => $app_name]);
@ -54,7 +54,7 @@ final class OAuthTokenManager
$this->entityManager->flush($tokenEntity);
//We are done
return;
return $tokenEntity;
}
//If the token was not existing, we create a new one
@ -63,7 +63,7 @@ final class OAuthTokenManager
//@phpstan-ignore-next-line
$this->entityManager->flush($tokenEntity);
return;
return $tokenEntity;
}
/**
@ -102,7 +102,14 @@ final class OAuthTokenManager
}
$client = $this->clientRegistry->getClient($app_name);
$new_token = $client->refreshAccessToken($token->getRefreshToken());
//Check if the token is refreshable or if it is an client credentials token
if ($token->isClientCredentialsGrant()) {
$new_token = $client->getOAuth2Provider()->getAccessToken('client_credentials');
} else {
//Otherwise we can use the refresh token to get a new access token
$new_token = $client->refreshAccessToken($token->getRefreshToken());
}
//Persist the token
$token->replaceWithNewToken($new_token);
@ -139,4 +146,20 @@ final class OAuthTokenManager
//And return the new token
return $token->getToken();
}
/**
* Retrieves an access token for the given app name using the client credentials grant (so no user flow is needed)
* The app_name must be registered in the knpu_oauth2_client.yaml
* The token is saved to the database, and afterward can be used as usual
* @param string $app_name
* @return OAuthToken
*/
public function retrieveClientCredentialsToken(string $app_name): OAuthToken
{
$client = $this->clientRegistry->getClient($app_name);
$access_token = $client->getOAuth2Provider()->getAccessToken('client_credentials');
return $this->saveToken($app_name, $access_token);
}
}