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/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 93d26d01..e775af8a 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 @@ -97,14 +107,27 @@ 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 = ""; 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/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/composer.json b/composer.json index 40322374..fd96da4b 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", @@ -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 346a75ac..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": "e851e50a8353b7633581464ad286c6f7", + "content-hash": "1c3a6a5bba2865b104630aaf4336e483", "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", @@ -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", @@ -3094,16 +3479,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 +3498,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 +3544,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 +3564,77 @@ "type": "tidelift" } ], - "time": "2022-03-02T17:24:08+00:00" + "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", @@ -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", @@ -13110,7 +13659,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 +13724,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 +13740,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 +13812,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 +13824,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 +13892,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 +15331,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 +15380,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 +15388,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 +15396,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 +15461,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 +15583,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 +15605,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 +15656,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 +15672,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 +15711,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 +15729,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 +15745,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 +15903,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 +15981,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 +16024,7 @@ "type": "tidelift" } ], - "time": "2023-07-11T01:32:50+00:00" + "time": "2023-07-14T22:04:26+00:00" }, { "name": "sebastian/diff", @@ -16234,6 +16787,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 +16802,7 @@ ], "minimum-stability": "stable", "stability-flags": { + "doctrine/orm": 20, "dompdf/dompdf": 20, "florianv/swap-bundle": 20, "roave/security-advisories": 20 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/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/config/packages/http_client.yaml b/config/packages/http_client.yaml new file mode 100644 index 00000000..2e693f7f --- /dev/null +++ b/config/packages/http_client.yaml @@ -0,0 +1,5 @@ +framework: + http_client: + default_options: + headers: + '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..f06bca1b --- /dev/null +++ b/config/packages/knpu_oauth2_client.yaml @@ -0,0 +1,23 @@ +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://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/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/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/config/services.yaml b/config/services.yaml index 300497de..d8be9967 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,37 @@ 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' + + App\Services\InfoProviderSystem\Providers\Element14Provider: + arguments: + $api_key: '%env(string:PROVIDER_ELEMENT14_KEY)%' + $store_id: '%env(string:PROVIDER_ELEMENT14_STORE_ID)%' + + App\Services\InfoProviderSystem\Providers\DigikeyProvider: + arguments: + $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(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/migrations/Version20230716184033.php b/migrations/Version20230716184033.php new file mode 100644 index 00000000..521e1b96 --- /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 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 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/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php new file mode 100644 index 00000000..cb95377b --- /dev/null +++ b/src/Controller/InfoProviderController.php @@ -0,0 +1,85 @@ +. + */ + +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 App\Services\Parts\PartFormHelper; +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) + { + + } + + #[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(), + ]); + } + + #[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); + + $results = null; + + if ($form->isSubmitted() && $form->isValid()) { + $keyword = $form->get('keyword')->getData(); + $providers = $form->get('providers')->getData(); + + $results = $this->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/Controller/OAuthClientController.php b/src/Controller/OAuthClientController.php new file mode 100644 index 00000000..ff2aab0e --- /dev/null +++ b/src/Controller/OAuthClientController.php @@ -0,0 +1,67 @@ +. + */ + +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 + { + $this->denyAccessUnlessGranted('@system.manage_oauth_tokens'); + + 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 + { + $this->denyAccessUnlessGranted('@system.manage_oauth_tokens'); + + $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/Controller/PartController.php b/src/Controller/PartController.php index 62dbaf4e..5b80a5cb 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 $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'], true)) { + 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,21 @@ class PartController extends AbstractController $this->addFlash('error', 'part.created_flash.invalid'); } - return $this->render('parts/edit/new_part.html.twig', + $template = ''; + 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/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/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/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php index eee8ebcc..5c4103d8 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(); @@ -413,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/Entity/OAuthToken.php b/src/Entity/OAuthToken.php new file mode 100644 index 00000000..30a8feef --- /dev/null +++ b/src/Entity/OAuthToken.php @@ -0,0 +1,134 @@ +. + */ + +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; +use League\OAuth2\Client\Token\AccessTokenInterface; + +/** + * 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 implements AccessTokenInterface +{ + /** @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 = ''; + + private const DEFAULT_EXPIRATION_TIME = 3600; + + 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'); + } + + $this->name = $name; + $this->refresh_token = $refresh_token; + $this->expires_at = $expires_at; + $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/Entity/Parts/InfoProviderReference.php b/src/Entity/Parts/InfoProviderReference.php new file mode 100644 index 00000000..53b81a0a --- /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, nullable: true, options: ['default' => null])] + 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/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/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..648cf2a5 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 Part + */ + public function setProviderReference(InfoProviderReference $providerReference): Part + { + $this->providerReference = $providerReference; + return $this; + } + + + } 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/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/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/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/Form/InfoProviderSystem/PartSearchType.php b/src/Form/InfoProviderSystem/PartSearchType.php new file mode 100644 index 00000000..9d582ca4 --- /dev/null +++ b/src/Form/InfoProviderSystem/PartSearchType.php @@ -0,0 +1,47 @@ +. + */ + +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): void + { + $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/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/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 309197ab..b15ec29f 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -22,6 +22,8 @@ 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; @@ -42,6 +44,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; @@ -49,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 { @@ -62,14 +66,8 @@ 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', - ]; + /** @var PartDetailDTO|null $dto */ + $dto = $options['info_provider_dto']; //Common section $builder @@ -101,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, ]) @@ -108,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, ]) @@ -128,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, [ @@ -140,9 +141,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, ]); @@ -273,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/Helper/StructuralEntityChoiceHelper.php b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php index 13e1626e..402270ce 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; } @@ -99,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 c2d35d92..df98e6ea 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -22,6 +22,8 @@ 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; @@ -32,13 +34,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; @@ -49,14 +59,24 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader public function createNewEntitiesFromValue(string $value): array { - if (!$this->options['allow_add']) { - 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); @@ -86,4 +106,25 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader return $this->additional_element; } + /** + * Gets the initial value used to populate the field. + * @return AbstractStructuralDBElement|null + */ + public function getStartingElement(): ?AbstractStructuralDBElement + { + return $this->starting_element; + } + + /** + * Sets the initial value used to populate the field. This will always be an allowed value. + * @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..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 { @@ -122,6 +143,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/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 c1882bda..24087cfa 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -185,4 +185,74 @@ 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 + * @phpstan-return TEntityClass|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 alternative names + $qb = $this->createQueryBuilder('e'); + //Use lowercase conversion to be case-insensitive + $qb->where($qb->expr()->like('LOWER(e.alternative_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 + * @phpstan-return TEntityClass + */ + 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); + + //Set the found name to the alternative names, so the entity can be easily renamed later + $entity->setAlternativeNames($name); + + $this->setNewEntityToCache($entity); + } + + return $entity; + } } diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php new file mode 100644 index 00000000..516ab949 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -0,0 +1,42 @@ +. + */ + +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( + public readonly string $url, + 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..f2a0d978 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -0,0 +1,100 @@ +. + */ + +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( + 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, + ) { + + } + + /** + * 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 + { + //Try to extract unit from value + $unit = null; + 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); + } + + //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/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php new file mode 100644 index 00000000..7a7a83ca --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -0,0 +1,71 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; + +/** + * This DTO represents a part with all its details. + */ +class PartDetailDTO extends SearchResultDTO +{ + public function __construct( + string $provider_key, + string $provider_id, + string $name, + string $description, + ?string $category = null, + ?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 FileDTO[]|null */ + public readonly ?array $images = null, + /** @var ParameterDTO[]|null */ + 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, + manufacturing_status: $manufacturing_status, + provider_url: $provider_url, + footprint: $footprint, + ); + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php new file mode 100644 index 00000000..8c563149 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -0,0 +1,57 @@ +. + */ + +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); + } + + /** + * Gets the price as BigDecimal + * @return BigDecimal + */ + 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..6073cc5f --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -0,0 +1,47 @@ +. + */ + +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( + 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/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php new file mode 100644 index 00000000..355041bf --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; + +/** + * This DTO represents a search result for a part. + */ +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 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 */ + 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, + /** @var string|null A footprint representation of the providers page */ + public readonly ?string $footprint = null, + ) { + + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php new file mode 100644 index 00000000..4a359a03 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -0,0 +1,292 @@ +. + */ + +declare(strict_types=1); + + +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\Footprint; +use App\Entity\Parts\InfoProviderReference; +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; + +/** + * This class converts DTOs to entities which can be persisted in the DB + */ +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); + $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; + } + + /** + * 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); + $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; + } + + /** + * Converts the given DTO to an orderdetail 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; + } + + /** + * 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)); + } 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->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); + + //Set the provider reference on the part + $entity->setProviderReference(InfoProviderReference::fromPartDTO($dto)); + + //Add parameters + foreach ($dto->parameters ?? [] as $parameter) { + $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) { + $entity->addAttachment($this->convertFile($datasheet, $datasheet_type)); + } + + //Add orderdetails and prices + foreach ($dto->vendor_infos ?? [] as $vendor_info) { + $entity->addOrderdetail($this->convertPurchaseInfo($vendor_info)); + } + + return $entity; + } + + /** + * 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 + * @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 + if ($name === null) { + return null; + } + + return $this->getOrCreateEntityNonNull($class, $name); + } + + /** + * 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 + * @param string $name The name of the entity to create + * @return AbstractStructuralDBElement + * @phpstan-return T + */ + private function getOrCreateEntityNonNull(string $class, string $name): AbstractStructuralDBElement + { + 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) + if ($iso_code === $this->base_currency) { + return null; + } + + 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'); + $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); + } + + 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/*'); + $tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME); + } + + return $tmp; + } + +} \ 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..f9bf4d84 --- /dev/null +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -0,0 +1,131 @@ +. + */ + +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; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +final class PartInfoRetriever +{ + + 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. 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 + */ + 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, $this->searchInProvider($provider, $keyword)); + } + + return $results; + } + + /** + * 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 PartDetailDTO + */ + public function getDetails(string $provider_key, string $part_id): PartDetailDTO + { + $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); + }); + } + + /** + * 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); + + return $this->dto_to_entity_converter->convertPart($details); + } +} \ 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..46f2484b --- /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; + +/** + * 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 + * @phpstan-var array + */ + private array $providers_by_name = []; + + /** + * @var InfoProviderInterface[] The enabled providers indexed by their keys + */ + private array $providers_active = []; + + /** + * @var InfoProviderInterface[] The disabled providers indexed by their keys + */ + private array $providers_disabled = []; + + /** + * @param iterable $providers + */ + public function __construct(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..f85db0aa --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -0,0 +1,269 @@ +. + */ + +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\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\OAuth\OAuthTokenManager; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +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, + 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" => $this->country, + "X-DIGIKEY-Locale-Language" => $this->language, + "X-DIGIKEY-Locale-Currency" => $this->currency, + "X-DIGIKEY-Customer-Id" => 0, + ] + ]); + } + + public function getProviderInfo(): array + { + return [ + '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, + 'disabled_help' => 'Set the PROVIDER_DIGIKEY_CLIENT_ID and PROVIDER_DIGIKEY_SECRET env option and connect OAuth to enable.' + ]; + } + + 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 + { + //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 + { + $request = [ + 'Keywords' => $keyword, + 'RecordCount' => 50, + 'RecordStartPosition' => 0, + 'ExcludeMarketPlaceProducts' => 'true', + ]; + + $response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [ + 'json' => $request, + 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) + ]); + + $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['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'], + ); + } + + 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); + $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']), + ); + } + + /** + * 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, + }; + } + + 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 + * @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 + { + $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) + ]; + } + + /** + * @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 diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php new file mode 100644 index 00000000..7cc6693b --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -0,0 +1,325 @@ +. + */ + +declare(strict_types=1); + + +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\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class Element14Provider implements InfoProviderInterface +{ + + private const ENDPOINT_URL = 'https://api.element14.com/catalog/products'; + 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']; + + public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id) + { + + } + + 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/', + 'disabled_help' => 'Configure the API key in the PROVIDER_ELEMENT14_KEY environment variable to enable.' + ]; + } + + public function getProviderKey(): string + { + return 'element14'; + } + + public function isActive(): bool + { + return !empty($this->api_key); + } + + /** + * @param string $term + * @return PartDetailDTO[] + */ + private function queryByTerm(string $term): array + { + $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ + 'query' => [ + 'term' => $term, + 'storeInfo.id' => $this->store_id, + 'resultsSettings.offset' => 0, + 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, + 'resultsSettings.responseGroup' => 'large', + 'callInfo.apiKey' => $this->api_key, + 'callInfo.responseDataFormat' => 'json', + 'callInfo.version' => self::API_VERSION_NUMBER, + ], + ]); + + $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: $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://' . $this->store_id . '/' . $sku; + } + + /** + * @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) { + 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://' . $this->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 ($this->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: ' . $this->store_id) + }; + } + + /** + * @param array|null $attributes + * @return ParameterDTO[] + */ + 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'], true)) { + $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 + $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 + { + return $this->queryByTerm('any:' . $keyword); + } + + public function getDetails(string $id): PartDetailDTO + { + $tmp = $this->queryByTerm('id:' . $id); + if (count($tmp) === 0) { + throw new \RuntimeException('No part found with ID ' . $id); + } + + if (count($tmp) > 1) { + throw new \RuntimeException('Multiple parts found with ID ' . $id); + } + + return $tmp[0]; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ]; + } +} \ 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..30821bad --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -0,0 +1,81 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +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 + * - 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, oauth_app_name?: 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; + + /** + * 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. + * 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/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..2d12b222 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -0,0 +1,296 @@ +. + */ + +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'; + + 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 bool $get_gross_prices) + { + + } + + public function getProviderInfo(): array + { + return [ + '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.' + ]; + } + + 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: !empty($product['OriginalSymbol']) ? $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); + + $footprint = null; + + $parameters = $this->getParameters($id, $footprint); + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['Symbol'], + name: !empty($product['OriginalSymbol']) ? $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, + footprint: $footprint, + datasheets: $files['datasheets'], + images: $files['images'], + parameters: $parameters, + 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, images: 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']), + ); + } + + //Extract images + $imageList = $files['AdditionalPhotoList']; + $images = []; + foreach($imageList as $image) { + $images[] = new FileDTO( + url: $this->normalizeURL($image['HighResolutionPhoto']), + ); + } + + + return [ + 'datasheets' => $datasheets, + 'images' => $images, + ]; + } + + /** + * 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 + * @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, string|null &$footprint_name = null): array + { + $response = $this->tmeClient->makeRequest('Products/GetParameters', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']['ProductList'][0]; + + $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; + } + + /** + * 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 diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php new file mode 100644 index 00000000..8b78c95a --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -0,0 +1,95 @@ +. + */ + +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 +{ + + 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 true; + } + + public function searchByKeyword(string $keyword): array + { + 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 + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ]; + } + + public function getDetails(string $id): PartDetailDTO + { + 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/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/src/Services/OAuth/OAuthTokenManager.php b/src/Services/OAuth/OAuthTokenManager.php new file mode 100644 index 00000000..020eead7 --- /dev/null +++ b/src/Services/OAuth/OAuthTokenManager.php @@ -0,0 +1,142 @@ +. + */ + +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); + + //@phpstan-ignore-next-line + $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); + //@phpstan-ignore-next-line + $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]); + } + + /** + * 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 + * @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); + + //@phpstan-ignore-next-line + $this->entityManager->flush($token); + + return $token; + } + + /** + * This function returns the token of the given app name + * @param string $app_name + * @return string|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/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/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/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/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; 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" }, 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/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" %} diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig new file mode 100644 index 00000000..7304806a --- /dev/null +++ b/templates/info_providers/providers.macro.html.twig @@ -0,0 +1,55 @@ +{% 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.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 %} +
+
+ {% 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..71e85175 --- /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/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig new file mode 100644 index 00000000..c28235c7 --- /dev/null +++ b/templates/info_providers/search/part_search.html.twig @@ -0,0 +1,101 @@ +{% 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.search.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}info_providers.search.title{% endtrans %} +{% endblock %} + +{% block card_content %} + + + + {{ 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 %} + + + + + + + + + {% endfor %} + + +
{% 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 %}
+ + + {% 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 }} +
+ + + +
+ {% endif %} + +{% endblock %} 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/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 } } 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/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/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)); + } +} 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'), + ]); + } +} 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*'); diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ffbd71df..6a321966 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11435,5 +11435,149 @@ Please note, that you can not impersonate a disabled user. If you try you will g User impersonated + + + info_providers.providers_list.title + Information 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 + + + + + part.info_provider_reference.badge + The information provider used to create this part. + + + + + part.info_provider_reference + Created by Information provider + + + + + oauth_client.connect.btn + 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 + + + + + info_providers.search.title + 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 + + + + + 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. + + + + + info_providers.form.help_prefix + Provider + +